diff --git a/src/food-market.pos.core/Data/PosStore.cs b/src/food-market.pos.core/Data/PosStore.cs index d223961..9aa805b 100644 --- a/src/food-market.pos.core/Data/PosStore.cs +++ b/src/food-market.pos.core/Data/PosStore.cs @@ -43,7 +43,8 @@ private void InitSchema() CREATE TABLE IF NOT EXISTS sale_queue( ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0, CreatedAt TEXT NOT NULL, Error TEXT); - CREATE TABLE IF NOT EXISTS kv(Key TEXT PRIMARY KEY, Value TEXT);"); + CREATE TABLE IF NOT EXISTS kv(Key TEXT PRIMARY KEY, Value TEXT); + CREATE TABLE IF NOT EXISTS stock(ProductId TEXT PRIMARY KEY, Quantity TEXT NOT NULL, AsOf TEXT);"); // Для БД, созданных до «Избранного» — добавляем колонки (SQLite не умеет // IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»). TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0"); @@ -113,6 +114,27 @@ public void ApplySync(PosSyncResponse sync) using var cmd = _cn.CreateCommand(); cmd.Transaction = tx; cmd.CommandText = "UPDATE products SET IsArchived=1 WHERE Id=$id"; Bind(cmd, "$id", did.ToString()); cmd.ExecuteNonQuery(); } + // Остатки — ПОЛНЫЙ снимок ненулевых остатков склада (сервер шлёт всё каждый + // раз, не дельту). Поэтому перезаписываем целиком: товар, вернувшийся к 0, + // выпадает из снимка → строка удаляется → GetStock вернёт null (=0, не минус). + using (var delStock = _cn.CreateCommand()) + { + delStock.Transaction = tx; delStock.CommandText = "DELETE FROM stock"; delStock.ExecuteNonQuery(); + } + if (sync.Stocks.Count > 0) + { + var insSt = _cn.CreateCommand(); insSt.Transaction = tx; + insSt.CommandText = "INSERT INTO stock(ProductId,Quantity,AsOf) VALUES($id,$q,$ao) ON CONFLICT(ProductId) DO UPDATE SET Quantity=$q, AsOf=$ao"; + var stP = AddParams(insSt, "$id", "$q", "$ao"); insSt.Prepare(); + foreach (var s in sync.Stocks) + { + stP["$id"].Value = s.ProductId.ToString(); + stP["$q"].Value = s.Quantity.ToString(System.Globalization.CultureInfo.InvariantCulture); + stP["$ao"].Value = s.AsOf.ToString("O"); + insSt.ExecuteNonQuery(); + } + insSt.Dispose(); + } tx.Commit(); SetKv("last_sync", sync.ServerTime.ToString("O")); } @@ -173,6 +195,44 @@ public IReadOnlyList SearchByName(string term, int limit = 50) using var r = cmd.ExecuteReader(); return r.Read() ? Map(r) : null; } + // ── остатки (локальный снимок с сервера; подсказка/предупреждение в минус) ── + /// Локальный остаток товара по последнему синку. null — нет записи + /// (трактуется как 0: товара на складе нет). + public decimal? GetStock(Guid productId) + { + using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT Quantity FROM stock WHERE ProductId=$id"; + Bind(cmd, "$id", productId.ToString()); + return cmd.ExecuteScalar() is string s + && decimal.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var q) + ? q : (decimal?)null; + } + + /// Сколько товаров сейчас в минусе на складе кассы (для индикатора в шапке). + public int NegativeCount() + { + using var cmd = _cn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM stock s JOIN products p ON p.Id=s.ProductId WHERE p.IsArchived=0 AND CAST(s.Quantity AS REAL) < 0"; + return Convert.ToInt32(cmd.ExecuteScalar()); + } + + /// Список товаров в минусе (имя, единица, остаток) — для списка по тапу на индикатор. + public IReadOnlyList NegativeStocks(int limit = 100) + { + using var cmd = _cn.CreateCommand(); + cmd.CommandText = @"SELECT p.Name, p.UnitCode, s.Quantity FROM stock s JOIN products p ON p.Id=s.ProductId + WHERE p.IsArchived=0 AND CAST(s.Quantity AS REAL) < 0 + ORDER BY CAST(s.Quantity AS REAL) LIMIT $l"; + Bind(cmd, "$l", limit); + var list = new List(); + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var q = decimal.Parse(r.GetString(2), System.Globalization.CultureInfo.InvariantCulture); + list.Add(new NegativeStockRow(r.GetString(0), r.GetString(1), q)); + } + return list; + } + // ── очередь продаж ───────────────────────────────────────────────────────── public void EnqueueSale(PosSaleDto sale) { diff --git a/src/food-market.pos.core/Models/PosModels.cs b/src/food-market.pos.core/Models/PosModels.cs index 59b8fb7..0fcb44e 100644 --- a/src/food-market.pos.core/Models/PosModels.cs +++ b/src/food-market.pos.core/Models/PosModels.cs @@ -3,6 +3,9 @@ namespace foodmarket.Pos.Core.Models; /// Тип упаковки (зеркало Domain.Packaging): штучный/весовой/разливной. public enum PosPackaging { Piece = 1, Weight = 2, Liquid = 3 } +/// Товар в минусе на складе кассы (локальный снимок) — для индикатора/списка. +public sealed record NegativeStockRow(string Name, string UnitCode, decimal Quantity); + public enum PosPaymentMethod { Cash = 0, Card = 1, BankTransfer = 2, Bonus = 3, Mixed = 99 } /// Товар в локальном каталоге кассы (из ProductSyncDto + системная цена). diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index 514ce3b..f887872 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -35,6 +35,35 @@ public partial class MainWindowViewModel : ViewModelBase /// Узкая шапка: сокращать тексты чипов (устанавливается из code-behind по ширине окна). [ObservableProperty] private bool _badgesCompact; + // ── Индикатор «товары в минусе» в шапке (постоянный, по складу кассы) ────── + [ObservableProperty] private int _negativeCount; + [ObservableProperty] private bool _isNegativeListOpen; + [ObservableProperty] private string _negativeListText = ""; + public bool HasNegatives => NegativeCount > 0; + public string NegativeBadge => BadgesCompact + ? $"{NegativeCount} в минусе" + : $"{NegativeCount} в минусе — нужна инвентаризация"; + partial void OnNegativeCountChanged(int value) + { OnPropertyChanged(nameof(HasNegatives)); OnPropertyChanged(nameof(NegativeBadge)); } + + /// Тап по жёлтому индикатору — показать/скрыть список товаров в минусе + /// (в XAML — Flyout; команда оставлена для программного/тестового доступа). + [RelayCommand] + private void ToggleNegativeList() => IsNegativeListOpen = !IsNegativeListOpen; + + /// Пересчитать счётчик минусов из локального снимка остатков и собрать + /// текст списка (работает офлайн). Вызывается при старте, после чека и после синка. + private void RefreshNegatives() + { + if (_svc is null) return; + NegativeCount = _svc.Store.NegativeCount(); + var rows = _svc.Store.NegativeStocks(50); + NegativeListText = rows.Count == 0 + ? "Товаров в минусе нет." + : string.Join("\n", rows.Select(r => $"• {r.Name}: {r.Quantity.ToString("0.###")} {r.UnitCode}")) + + "\n\nПроведите инвентаризацию по этим товарам."; + } + public string NetBadge => IsOnline ? "На связи" : "Нет связи"; public string ShiftBadge => IsShiftOpen ? (BadgesCompact ? "Смена открыта" : $"Смена {ShiftNumber}") @@ -44,10 +73,10 @@ public partial class MainWindowViewModel : ViewModelBase partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); } partial void OnIsShiftOpenChanged(bool value) => OnPropertyChanged(nameof(ShiftBadge)); partial void OnShiftNumberChanged(string value) => OnPropertyChanged(nameof(ShiftBadge)); - partial void OnBadgesCompactChanged(bool value) { OnPropertyChanged(nameof(ShiftBadge)); OnPropertyChanged(nameof(QueueBadge)); } + partial void OnBadgesCompactChanged(bool value) { OnPropertyChanged(nameof(ShiftBadge)); OnPropertyChanged(nameof(QueueBadge)); OnPropertyChanged(nameof(NegativeBadge)); } /// Конструктор для дизайнера/скриншотов (демо-данные). - public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Виктория Ким"; ShiftNumber = "С-2026-000012 открыта с 09:00"; IsShiftOpen = true; StoreName = "Магазин у дома"; PointName = "Касса 1"; } + public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Виктория Ким"; ShiftNumber = "С-2026-000012 открыта с 09:00"; IsShiftOpen = true; StoreName = "Магазин у дома"; PointName = "Касса 1"; NegativeCount = 3; NegativeListText = "• Сахар-песок 1кг: -2 шт\n• Молоко 2.5% 1л: -1 шт\n• Хлеб «Семейный»: -4 шт\n\nПроведите инвентаризацию по этим товарам."; } public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); } @@ -61,6 +90,7 @@ private void StartSession() { CashierName = _svc!.Session?.CashierName ?? "кассир"; QueueCount = _svc.Store.PendingCount(); + RefreshNegatives(); _ = ResolvePointNameAsync(); _sale = new SaleViewModel(_svc) { @@ -119,7 +149,7 @@ private static string LocalVersion() return System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0"; } - private void RefreshStatus() { if (_svc is not null) QueueCount = _svc.Store.PendingCount(); } + private void RefreshStatus() { if (_svc is not null) { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); } } [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; } }; @@ -179,7 +209,7 @@ private async Task SyncTickAsync() IsOnline = true; } catch { IsOnline = false; } - finally { QueueCount = _svc.Store.PendingCount(); } + finally { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); } } } @@ -375,6 +405,11 @@ public partial class SaleLineVm : ObservableObject [ObservableProperty] private decimal _price; [ObservableProperty] private decimal _quantity = 1m; [ObservableProperty] private decimal _discount; + /// Предупреждение «уйдёт в минус» прямо в строке (не блокирует продажу). + /// Пусто — остатка хватает. Заполняется из локального снимка остатков кассы. + [ObservableProperty] private string? _stockWarning; + public bool HasStockWarning => !string.IsNullOrEmpty(StockWarning); + partial void OnStockWarningChanged(string? value) => OnPropertyChanged(nameof(HasStockWarning)); public decimal Gross => decimal.Round(Price * Quantity, 2); public decimal LineTotal => Math.Max(0m, Gross - Discount); /// Количество с единицей измерения: «3 шт», «1,235 кг». @@ -471,10 +506,27 @@ public void AddProduct(PosProduct p, decimal qty, decimal? priceOverride = null) Lines.Insert(0, vm); } SelectedLine = vm; + UpdateStockWarning(vm); Recalc(); Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + line.Quantity.ToString("0"))}"; } + /// Предупреждение «уйдёт в минус» для строки: сравниваем количество в + /// чеке с локальным остатком (снимок последнего синка, работает офлайн). + /// Продажу НЕ блокируем (решение владельца) — только подсвечиваем. + private void UpdateStockWarning(SaleLineVm vm) + { + if (_svc is null) { vm.StockWarning = null; return; } + var onHand = _svc.Store.GetStock(vm.ProductId) ?? 0m; + if (vm.Quantity > onHand) + { + vm.StockWarning = onHand <= 0m + ? $"нет на остатке — уйдёт в минус" + : $"на остатке {onHand.ToString("0.###")} {vm.Unit} — уйдёт в минус"; + } + else vm.StockWarning = null; + } + [RelayCommand] private void Scan() { @@ -505,7 +557,7 @@ public void SetLineQuantity(SaleLineVm line, decimal qty) if (line.Model is null) return; _cart.SetQuantity(line.Model, qty); if (qty <= 0) { Lines.Remove(line); } - else line.Quantity = line.Model.Quantity; + else { line.Quantity = line.Model.Quantity; UpdateStockWarning(line); } Recalc(); } @@ -594,7 +646,8 @@ public static SaleViewModel Sample() vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный» нарезной", Unit = "шт", Price = 280m, Quantity = 2 }); vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", Price = 450m, Quantity = 1 }); vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Яблоки «Апорт» (вес)", Unit = "кг", Price = 690m, Quantity = 1.235m }); - vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Сахар-песок 1кг", Unit = "шт", Price = 520m, Quantity = 3, Discount = 60m }); + vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Сахар-песок 1кг", Unit = "шт", Price = 520m, Quantity = 3, Discount = 60m, + StockWarning = "на остатке 2 шт — уйдёт в минус" }); vm.Subtotal = vm.Lines.Sum(l => l.Price * l.Quantity); vm.Discount = 60m; vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2); diff --git a/src/food-market.pos/Views/MainWindow.axaml b/src/food-market.pos/Views/MainWindow.axaml index c449c1b..5527c2f 100644 --- a/src/food-market.pos/Views/MainWindow.axaml +++ b/src/food-market.pos/Views/MainWindow.axaml @@ -39,6 +39,24 @@ + + diff --git a/src/food-market.pos/Views/SaleView.axaml b/src/food-market.pos/Views/SaleView.axaml index 2363fb3..38da232 100644 --- a/src/food-market.pos/Views/SaleView.axaml +++ b/src/food-market.pos/Views/SaleView.axaml @@ -95,6 +95,11 @@ + + + + + diff --git a/tests/food-market.UnitTests/PosCoreTests.cs b/tests/food-market.UnitTests/PosCoreTests.cs index d51f356..bf12203 100644 --- a/tests/food-market.UnitTests/PosCoreTests.cs +++ b/tests/food-market.UnitTests/PosCoreTests.cs @@ -143,6 +143,57 @@ private static PosSyncResponse Sync(params (Guid id, string name, string bc, dec Stocks = Array.Empty(), Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), }; + private static PosSyncResponse SyncWithStocks( + (Guid id, string name, decimal price, int pack)[] products, + (Guid id, decimal qty)[] stocks) => new() + { + ServerTime = DateTime.UtcNow, + Products = products.Select(i => new ProductSyncDto + { + Id = i.id, Name = i.name, Barcodes = Array.Empty(), UnitCode = i.pack == 2 ? "кг" : "шт", + Packaging = i.pack, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow, + }).ToList(), + Prices = products.Select(i => new PriceSyncDto + { + ProductId = i.id, PriceTypeId = Guid.NewGuid(), PriceTypeName = "Розн", IsSystem = true, + Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow, + }).ToList(), + Stocks = stocks.Select(x => new StockSyncDto { ProductId = x.id, StoreId = Guid.NewGuid(), Quantity = x.qty, AsOf = DateTime.UtcNow }).ToList(), + Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), + }; + + [Fact] + public void Stocks_persisted_negatives_counted_and_listed() + { + using var s = PosStore.InMemory(); + var a = Guid.NewGuid(); var b = Guid.NewGuid(); var c = Guid.NewGuid(); + s.ApplySync(SyncWithStocks( + new[] { (a, "Молоко", 450m, 1), (b, "Сахар", 520m, 1), (c, "Яблоки", 690m, 2) }, + new[] { (a, 5m), (b, -2m), (c, -1.5m) })); + + s.GetStock(a).Should().Be(5m); + s.GetStock(b).Should().Be(-2m); + s.GetStock(Guid.NewGuid()).Should().BeNull("нет записи = остаток неизвестен (0)"); + s.NegativeCount().Should().Be(2); + var list = s.NegativeStocks(); + list.Should().HaveCount(2); + list[0].Quantity.Should().Be(-2m, "сортировка по возрастанию остатка (самый глубокий минус первым)"); + } + + [Fact] + public void Stocks_are_full_snapshot_replaced_each_sync() + { + using var s = PosStore.InMemory(); + var a = Guid.NewGuid(); var b = Guid.NewGuid(); + s.ApplySync(SyncWithStocks(new[] { (a, "A", 10m, 1), (b, "B", 10m, 1) }, new[] { (a, -3m), (b, -1m) })); + s.NegativeCount().Should().Be(2); + + // Следующий синк: A вернулся к 0 (выпал из снимка), B всё ещё -1. + s.ApplySync(SyncWithStocks(new[] { (a, "A", 10m, 1), (b, "B", 10m, 1) }, new[] { (b, -1m) })); + s.GetStock(a).Should().BeNull("A вернулся к 0 → выпал из снимка остатков"); + s.NegativeCount().Should().Be(1, "в минусе остался только B"); + } + [Fact] public void Catalog_find_by_barcode_and_name() {