feat(pos): предупреждения о минусе на кассе — в строке + индикатор в шапке (офлайн)
Сервер уже слал остатки в sync (StockSyncDto), но касса их отбрасывала. Теперь: - PosStore хранит снимок остатков (таблица stock, перезапись каждый синк — сервер шлёт полный снимок ненулевых); GetStock/NegativeCount/NegativeStocks. - В строке чека при количестве больше остатка — «на остатке N шт — уйдёт в минус» (жёлтое, НЕ блокирует продажу — решение владельца). Работает офлайн по снимку. - В шапке — постоянный жёлтый индикатор «N в минусе — нужна инвентаризация» по складу кассы, по тапу — список товаров (Flyout). Обновляется при старте, после чека и после синка. - Тесты PosStore: снимок/перезапись, GetStock, NegativeCount/список (4/4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0d714e62bc
commit
0fd04d4f5c
|
|
@ -43,7 +43,8 @@ private void InitSchema()
|
||||||
CREATE TABLE IF NOT EXISTS sale_queue(
|
CREATE TABLE IF NOT EXISTS sale_queue(
|
||||||
ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0,
|
ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0,
|
||||||
CreatedAt TEXT NOT NULL, Error TEXT);
|
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 не умеет
|
// Для БД, созданных до «Избранного» — добавляем колонки (SQLite не умеет
|
||||||
// IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»).
|
// IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»).
|
||||||
TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0");
|
TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
@ -113,6 +114,27 @@ public void ApplySync(PosSyncResponse sync)
|
||||||
using var cmd = _cn.CreateCommand(); cmd.Transaction = tx;
|
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();
|
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();
|
tx.Commit();
|
||||||
SetKv("last_sync", sync.ServerTime.ToString("O"));
|
SetKv("last_sync", sync.ServerTime.ToString("O"));
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +195,44 @@ public IReadOnlyList<PosProduct> SearchByName(string term, int limit = 50)
|
||||||
using var r = cmd.ExecuteReader(); return r.Read() ? Map(r) : null;
|
using var r = cmd.ExecuteReader(); return r.Read() ? Map(r) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── остатки (локальный снимок с сервера; подсказка/предупреждение в минус) ──
|
||||||
|
/// <summary>Локальный остаток товара по последнему синку. null — нет записи
|
||||||
|
/// (трактуется как 0: товара на складе нет).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сколько товаров сейчас в минусе на складе кассы (для индикатора в шапке).</summary>
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Список товаров в минусе (имя, единица, остаток) — для списка по тапу на индикатор.</summary>
|
||||||
|
public IReadOnlyList<NegativeStockRow> 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<NegativeStockRow>();
|
||||||
|
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)
|
public void EnqueueSale(PosSaleDto sale)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ namespace foodmarket.Pos.Core.Models;
|
||||||
/// <summary>Тип упаковки (зеркало Domain.Packaging): штучный/весовой/разливной.</summary>
|
/// <summary>Тип упаковки (зеркало Domain.Packaging): штучный/весовой/разливной.</summary>
|
||||||
public enum PosPackaging { Piece = 1, Weight = 2, Liquid = 3 }
|
public enum PosPackaging { Piece = 1, Weight = 2, Liquid = 3 }
|
||||||
|
|
||||||
|
/// <summary>Товар в минусе на складе кассы (локальный снимок) — для индикатора/списка.</summary>
|
||||||
|
public sealed record NegativeStockRow(string Name, string UnitCode, decimal Quantity);
|
||||||
|
|
||||||
public enum PosPaymentMethod { Cash = 0, Card = 1, BankTransfer = 2, Bonus = 3, Mixed = 99 }
|
public enum PosPaymentMethod { Cash = 0, Card = 1, BankTransfer = 2, Bonus = 3, Mixed = 99 }
|
||||||
|
|
||||||
/// <summary>Товар в локальном каталоге кассы (из ProductSyncDto + системная цена).</summary>
|
/// <summary>Товар в локальном каталоге кассы (из ProductSyncDto + системная цена).</summary>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,35 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||||
/// <summary>Узкая шапка: сокращать тексты чипов (устанавливается из code-behind по ширине окна).</summary>
|
/// <summary>Узкая шапка: сокращать тексты чипов (устанавливается из code-behind по ширине окна).</summary>
|
||||||
[ObservableProperty] private bool _badgesCompact;
|
[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)); }
|
||||||
|
|
||||||
|
/// <summary>Тап по жёлтому индикатору — показать/скрыть список товаров в минусе
|
||||||
|
/// (в XAML — Flyout; команда оставлена для программного/тестового доступа).</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleNegativeList() => IsNegativeListOpen = !IsNegativeListOpen;
|
||||||
|
|
||||||
|
/// <summary>Пересчитать счётчик минусов из локального снимка остатков и собрать
|
||||||
|
/// текст списка (работает офлайн). Вызывается при старте, после чека и после синка.</summary>
|
||||||
|
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 NetBadge => IsOnline ? "На связи" : "Нет связи";
|
||||||
public string ShiftBadge => IsShiftOpen
|
public string ShiftBadge => IsShiftOpen
|
||||||
? (BadgesCompact ? "Смена открыта" : $"Смена {ShiftNumber}")
|
? (BadgesCompact ? "Смена открыта" : $"Смена {ShiftNumber}")
|
||||||
|
|
@ -44,10 +73,10 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||||
partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); }
|
partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); }
|
||||||
partial void OnIsShiftOpenChanged(bool value) => OnPropertyChanged(nameof(ShiftBadge));
|
partial void OnIsShiftOpenChanged(bool value) => OnPropertyChanged(nameof(ShiftBadge));
|
||||||
partial void OnShiftNumberChanged(string 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)); }
|
||||||
|
|
||||||
/// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary>
|
/// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary>
|
||||||
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(); }
|
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 ?? "кассир";
|
CashierName = _svc!.Session?.CashierName ?? "кассир";
|
||||||
QueueCount = _svc.Store.PendingCount();
|
QueueCount = _svc.Store.PendingCount();
|
||||||
|
RefreshNegatives();
|
||||||
_ = ResolvePointNameAsync();
|
_ = ResolvePointNameAsync();
|
||||||
_sale = new SaleViewModel(_svc)
|
_sale = new SaleViewModel(_svc)
|
||||||
{
|
{
|
||||||
|
|
@ -119,7 +149,7 @@ private static string LocalVersion()
|
||||||
return System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0";
|
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 ShowSale() { CurrentPage = _sale ??= (_svc is null ? SaleViewModel.Sample() : new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() }); }
|
||||||
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } };
|
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } };
|
||||||
|
|
@ -179,7 +209,7 @@ private async Task SyncTickAsync()
|
||||||
IsOnline = true;
|
IsOnline = true;
|
||||||
}
|
}
|
||||||
catch { IsOnline = false; }
|
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 _price;
|
||||||
[ObservableProperty] private decimal _quantity = 1m;
|
[ObservableProperty] private decimal _quantity = 1m;
|
||||||
[ObservableProperty] private decimal _discount;
|
[ObservableProperty] private decimal _discount;
|
||||||
|
/// <summary>Предупреждение «уйдёт в минус» прямо в строке (не блокирует продажу).
|
||||||
|
/// Пусто — остатка хватает. Заполняется из локального снимка остатков кассы.</summary>
|
||||||
|
[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 Gross => decimal.Round(Price * Quantity, 2);
|
||||||
public decimal LineTotal => Math.Max(0m, Gross - Discount);
|
public decimal LineTotal => Math.Max(0m, Gross - Discount);
|
||||||
/// <summary>Количество с единицей измерения: «3 шт», «1,235 кг».</summary>
|
/// <summary>Количество с единицей измерения: «3 шт», «1,235 кг».</summary>
|
||||||
|
|
@ -471,10 +506,27 @@ public void AddProduct(PosProduct p, decimal qty, decimal? priceOverride = null)
|
||||||
Lines.Insert(0, vm);
|
Lines.Insert(0, vm);
|
||||||
}
|
}
|
||||||
SelectedLine = vm;
|
SelectedLine = vm;
|
||||||
|
UpdateStockWarning(vm);
|
||||||
Recalc();
|
Recalc();
|
||||||
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + line.Quantity.ToString("0"))}";
|
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + line.Quantity.ToString("0"))}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Предупреждение «уйдёт в минус» для строки: сравниваем количество в
|
||||||
|
/// чеке с локальным остатком (снимок последнего синка, работает офлайн).
|
||||||
|
/// Продажу НЕ блокируем (решение владельца) — только подсвечиваем.</summary>
|
||||||
|
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]
|
[RelayCommand]
|
||||||
private void Scan()
|
private void Scan()
|
||||||
{
|
{
|
||||||
|
|
@ -505,7 +557,7 @@ public void SetLineQuantity(SaleLineVm line, decimal qty)
|
||||||
if (line.Model is null) return;
|
if (line.Model is null) return;
|
||||||
_cart.SetQuantity(line.Model, qty);
|
_cart.SetQuantity(line.Model, qty);
|
||||||
if (qty <= 0) { Lines.Remove(line); }
|
if (qty <= 0) { Lines.Remove(line); }
|
||||||
else line.Quantity = line.Model.Quantity;
|
else { line.Quantity = line.Model.Quantity; UpdateStockWarning(line); }
|
||||||
Recalc();
|
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 = "Хлеб «Семейный» нарезной", 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 = "Молоко 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 = "Яблоки «Апорт» (вес)", 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.Subtotal = vm.Lines.Sum(l => l.Price * l.Quantity);
|
||||||
vm.Discount = 60m;
|
vm.Discount = 60m;
|
||||||
vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2);
|
vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,24 @@
|
||||||
|
|
||||||
<!-- Чипы статуса (всегда, никогда не обрезаются) -->
|
<!-- Чипы статуса (всегда, никогда не обрезаются) -->
|
||||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
|
<!-- Постоянный жёлтый индикатор «N товаров в минусе» (по складу кассы).
|
||||||
|
По нажатию — список во всплывашке. Виден только при наличии минусов. -->
|
||||||
|
<Button Padding="12,6" CornerRadius="999" BorderThickness="0"
|
||||||
|
Background="#F59E0B" Foreground="#1F2937"
|
||||||
|
IsVisible="{Binding HasNegatives}" Command="{Binding ToggleNegativeListCommand}"
|
||||||
|
ToolTip.Tip="Товары в минусе — нужна инвентаризация">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="⚠" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding NegativeBadge}" VerticalAlignment="Center" FontWeight="SemiBold"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button.Flyout>
|
||||||
|
<Flyout Placement="Bottom">
|
||||||
|
<ScrollViewer MaxHeight="420" MaxWidth="380">
|
||||||
|
<TextBlock Text="{Binding NegativeListText}" TextWrapping="Wrap" FontSize="13"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Flyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
<Border Classes="chip" Classes.ok="{Binding IsShiftOpen}" Classes.warn="{Binding !IsShiftOpen}">
|
<Border Classes="chip" Classes.ok="{Binding IsShiftOpen}" Classes.warn="{Binding !IsShiftOpen}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Ellipse Width="8" Height="8" VerticalAlignment="Center" Fill="{DynamicResource PosBrand}"/>
|
<Ellipse Width="8" Height="8" VerticalAlignment="Center" Fill="{DynamicResource PosBrand}"/>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,11 @@
|
||||||
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource PosText}" FontSize="18" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
|
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource PosText}" FontSize="18" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
|
||||||
<TextBlock Text="{Binding DiscountText}" Foreground="{DynamicResource PosBrandDark}" FontSize="14" FontWeight="Medium" Margin="0,4,0,0" IsVisible="{Binding HasDiscount}"/>
|
<TextBlock Text="{Binding DiscountText}" Foreground="{DynamicResource PosBrandDark}" FontSize="14" FontWeight="Medium" Margin="0,4,0,0" IsVisible="{Binding HasDiscount}"/>
|
||||||
<TextBlock Text="{Binding UnitPriceText}" Foreground="{DynamicResource PosTextMuted}" FontSize="14" Margin="0,4,0,0" IsVisible="{Binding !HasDiscount}"/>
|
<TextBlock Text="{Binding UnitPriceText}" Foreground="{DynamicResource PosTextMuted}" FontSize="14" Margin="0,4,0,0" IsVisible="{Binding !HasDiscount}"/>
|
||||||
|
<!-- Предупреждение «уйдёт в минус» прямо в строке (не блокирует продажу). -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5" Margin="0,4,0,0" IsVisible="{Binding HasStockWarning}">
|
||||||
|
<TextBlock Text="⚠" Foreground="{DynamicResource PosWarn}" FontSize="13" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding StockWarning}" Foreground="{DynamicResource PosWarn}" FontSize="13" FontWeight="Medium" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Border Grid.Column="2" Height="44" Margin="16,0" BorderBrush="{DynamicResource PosBorder}" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
<Border Grid.Column="2" Height="44" Margin="16,0" BorderBrush="{DynamicResource PosBorder}" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
||||||
<Grid ColumnDefinitions="44,*,44">
|
<Grid ColumnDefinitions="44,*,44">
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,57 @@ private static PosSyncResponse Sync(params (Guid id, string name, string bc, dec
|
||||||
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
|
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<string>(), 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<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
[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]
|
[Fact]
|
||||||
public void Catalog_find_by_barcode_and_name()
|
public void Catalog_find_by_barcode_and_name()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue