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(
|
||||
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<PosProduct> SearchByName(string term, int limit = 50)
|
|||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ namespace foodmarket.Pos.Core.Models;
|
|||
/// <summary>Тип упаковки (зеркало Domain.Packaging): штучный/весовой/разливной.</summary>
|
||||
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 }
|
||||
|
||||
/// <summary>Товар в локальном каталоге кассы (из ProductSyncDto + системная цена).</summary>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,35 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||
/// <summary>Узкая шапка: сокращать тексты чипов (устанавливается из code-behind по ширине окна).</summary>
|
||||
[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 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)); }
|
||||
|
||||
/// <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(); }
|
||||
|
||||
|
|
@ -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;
|
||||
/// <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 LineTotal => Math.Max(0m, Gross - Discount);
|
||||
/// <summary>Количество с единицей измерения: «3 шт», «1,235 кг».</summary>
|
||||
|
|
@ -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"))}";
|
||||
}
|
||||
|
||||
/// <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]
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,24 @@
|
|||
|
||||
<!-- Чипы статуса (всегда, никогда не обрезаются) -->
|
||||
<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}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<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 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}"/>
|
||||
<!-- Предупреждение «уйдёт в минус» прямо в строке (не блокирует продажу). -->
|
||||
<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>
|
||||
<Border Grid.Column="2" Height="44" Margin="16,0" BorderBrush="{DynamicResource PosBorder}" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
||||
<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>(),
|
||||
};
|
||||
|
||||
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]
|
||||
public void Catalog_find_by_barcode_and_name()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue