feat(pos): предупреждения о минусе на кассе — в строке + индикатор в шапке (офлайн)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

Сервер уже слал остатки в 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:
nns 2026-10-09 03:38:50 +05:00
parent 0d714e62bc
commit 0fd04d4f5c
6 changed files with 197 additions and 7 deletions

View file

@ -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)
{ {

View file

@ -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>

View file

@ -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);

View file

@ -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}"/>

View file

@ -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">

View file

@ -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()
{ {