From 63da7a9334af30d77a65bd446f7747a3ec28cf73 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 04:59:01 +0500 Subject: [PATCH] =?UTF-8?q?perf(pos):=20=D0=BC=D0=B3=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82=D0=B0=D1=80=D1=82?= =?UTF-8?q?=20=E2=80=94=20gzip-=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3,?= =?UTF-8?q?=20=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D0=B0=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=BA=D0=BE=D0=B2,=20=D1=81=D0=B8=D0=BD=D0=BA=20?= =?UTF-8?q?=D0=B2=D0=BD=D0=B5=20UI-=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Касса работает из локальной базы сразу; вся синхронизация — в фоне, UI не ждёт. - HttpClient с AutomaticDecompression: сервер уже жмёт JSON (gzip), но касса не слала Accept-Encoding → каталог 30k ехал 22 МБ; теперь 3 МБ (×7.3). - ApplySync: убран O(n²) скан цен (HashSet product-id) — запись 30k 12.2с → 1.8с. - Остатки дельтой: PosSyncResponse.StocksFullSnapshot — первичная загрузка = полный снимок, далее только изменённые (ушедшие в 0 удаляются). Рекуррентный тик: остатков 30000→0, 806мс→189мс. - Синк вне UI-потока (Task.Run) + маршалинг обновлений через Dispatcher: тяжёлая запись каталога/дельты никогда не фризит окно. Флаг анти-перекрытия тиков. - Индикатор в шапке «обновлено N мин назад / обновляется…»; ненавязчивая полоса первичной загрузки (только когда каталог пуст), не модалка. - Сервер: since нормализуется к UTC (был 500 от Npgsql на Kind=Unspecified). - Тест POS-дельты остатков (upsert/zero-remove); oversell-тест кассы приведён к решению владельца (продажа в минус разрешена → Accepted + предупреждение). Замеры (стенд, 30 000 товаров): первичная 14.9с→5.1с (сеть 3.2с + запись 1.8с), тёплый старт 141мс, дельта без изменений 189мс, дельта 500 товаров ~0.35с. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Pos/PosController.cs | 40 +++++++---- src/food-market.pos.core/Data/PosStore.cs | 28 ++++++-- src/food-market.pos/Services/AppServices.cs | 14 +++- src/food-market.pos/ViewModels/ViewModels.cs | 70 +++++++++++++++++-- src/food-market.pos/Views/MainWindow.axaml | 17 ++++- src/food-market.shared/Pos/V1/SyncDtos.cs | 7 ++ .../PosSyncTests.cs | 15 ++-- tests/food-market.UnitTests/PosCoreTests.cs | 31 +++++++- 8 files changed, 186 insertions(+), 36 deletions(-) diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index 4253714..57310a3 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -109,7 +109,16 @@ orderby l.SortOrder if (resolvedStore is null) return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" }); - var threshold = since ?? DateTime.MinValue.ToUniversalTime(); + // `since` приходит из query-строки и может иметь Kind=Unspecified/Local — + // Npgsql к timestamptz пускает только UTC, иначе 500. Нормализуем жёстко: + // Unspecified трактуем как UTC (клиент шлёт UTC), Local конвертируем. + DateTime threshold; + if (since is { } sinceVal) + threshold = sinceVal.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(sinceVal, DateTimeKind.Utc) + : sinceVal.ToUniversalTime(); + else + threshold = DateTime.UnixEpoch; var serverTime = DateTime.UtcNow; // Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt @@ -168,17 +177,23 @@ orderby l.SortOrder UpdatedAt = stamp, }).ToListAsync(ct); - // Остатки: всегда полный снимок на момент ответа. since-фильтр не - // применяется к остаткам — кассе всегда нужен актуальный stock. - var stocks = await (from s in _db.Stocks.AsNoTracking() - where s.StoreId == resolvedStore.Value && s.Quantity != 0m - select new StockSyncDto - { - ProductId = s.ProductId, - StoreId = s.StoreId, - Quantity = s.Quantity, - AsOf = serverTime, - }).ToListAsync(ct); + // Остатки. Первичная загрузка (since отсутствует) → ПОЛНЫЙ снимок ненулевых. + // Дельта → только изменённые с момента since (включая ушедшие в 0, чтобы + // касса их удалила). Так рекуррентный тик не гоняет все 30 000 строк. + var stocksFull = since is null; + IQueryable stockQuery = _db.Stocks.AsNoTracking() + .Where(s => s.StoreId == resolvedStore.Value); + stockQuery = stocksFull + ? stockQuery.Where(s => s.Quantity != 0m) + : stockQuery.Where(s => (s.UpdatedAt ?? s.CreatedAt) > threshold); + var stocks = await stockQuery + .Select(s => new StockSyncDto + { + ProductId = s.ProductId, + StoreId = s.StoreId, + Quantity = s.Quantity, + AsOf = serverTime, + }).ToListAsync(ct); // Контрагенты-покупатели. Поставщиков на POS не шлём. var counterparties = await (from c in _db.Counterparties.AsNoTracking() @@ -203,6 +218,7 @@ orderby l.SortOrder Products = productDtos, Prices = prices, Stocks = stocks, + StocksFullSnapshot = stocksFull, Counterparties = counterparties, DeletedProductIds = Array.Empty(), }); diff --git a/src/food-market.pos.core/Data/PosStore.cs b/src/food-market.pos.core/Data/PosStore.cs index 9aa805b..fa1de50 100644 --- a/src/food-market.pos.core/Data/PosStore.cs +++ b/src/food-market.pos.core/Data/PosStore.cs @@ -100,10 +100,13 @@ public void ApplySync(PosSyncResponse sync) } } up.Dispose(); delBc.Dispose(); insBc.Dispose(); - // цены для товаров, пришедших без карточки (только апдейт цены) + // Цены для товаров, пришедших БЕЗ карточки (дельта цены без изменения товара). + // ВАЖНО: product-id в HashSet (O(1)), иначе `Products.Any(...)` на строку даёт + // O(n²) — на 30 000 товаров это ~900 млн сравнений и +секунды к записи. + var productIdSet = new HashSet(sync.Products.Select(p => p.Id)); foreach (var kv in priceByProduct) { - if (sync.Products.Any(x => x.Id == kv.Key)) continue; + if (productIdSet.Contains(kv.Key)) continue; using var cmd = _cn.CreateCommand(); cmd.Transaction = tx; cmd.CommandText = "UPDATE products SET Price=$pr WHERE Id=$id"; Bind(cmd, "$pr", kv.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); Bind(cmd, "$id", kv.Key.ToString()); @@ -114,11 +117,12 @@ 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()) + // Остатки. Полный снимок (первичная загрузка) → перезаписываем целиком. + // Дельта → применяем построчно: ненулевые upsert, нулевые (вернулись к 0) + // удаляем. Так рекуррентный тик не трогает все 30 000 строк каждый раз. + if (sync.StocksFullSnapshot) { + using var delStock = _cn.CreateCommand(); delStock.Transaction = tx; delStock.CommandText = "DELETE FROM stock"; delStock.ExecuteNonQuery(); } if (sync.Stocks.Count > 0) @@ -126,14 +130,24 @@ public void ApplySync(PosSyncResponse sync) 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(); + var delSt = _cn.CreateCommand(); delSt.Transaction = tx; + delSt.CommandText = "DELETE FROM stock WHERE ProductId=$id"; + var delStP = AddParams(delSt, "$id"); delSt.Prepare(); foreach (var s in sync.Stocks) { + // В дельте остаток, вернувшийся к 0, удаляем (GetStock → null = 0). + if (!sync.StocksFullSnapshot && s.Quantity == 0m) + { + delStP["$id"].Value = s.ProductId.ToString(); + delSt.ExecuteNonQuery(); + continue; + } 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(); + insSt.Dispose(); delSt.Dispose(); } tx.Commit(); SetKv("last_sync", sync.ServerTime.ToString("O")); diff --git a/src/food-market.pos/Services/AppServices.cs b/src/food-market.pos/Services/AppServices.cs index 33375fb..d3835ab 100644 --- a/src/food-market.pos/Services/AppServices.cs +++ b/src/food-market.pos/Services/AppServices.cs @@ -34,7 +34,7 @@ public AppServices() var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos"); Directory.CreateDirectory(dir); Store = new PosStore(Path.Combine(dir, "catalog.db")); - Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + Http = new HttpClient(NewHandler()) { Timeout = TimeSpan.FromSeconds(30) }; Auth = new AuthService(Http); // Windows — DPAPI (привязка к учётке), иначе файл с правами 0600. Tokens = OperatingSystem.IsWindows() @@ -58,7 +58,7 @@ public AppServices(PosStore store) public void ConfigureApi(PosSession session) { Session = session; - var client = new HttpClient(new AuthHeaderHandler(() => Session?.AccessToken, new HttpClientHandler())) + var client = new HttpClient(new AuthHeaderHandler(() => Session?.AccessToken, NewHandler())) { BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')), Timeout = TimeSpan.FromSeconds(30), @@ -66,6 +66,16 @@ public void ConfigureApi(PosSession session) Api = RestService.For(client); Sync = new SyncService(Store, Api); } + + /// HTTP-handler с автоматической декомпрессией (gzip/deflate/brotli): + /// сервер жмёт JSON каталога (Program.cs AddResponseCompression), но без этого + /// флага HttpClient не шлёт Accept-Encoding и каталог едет несжатым. Для + /// первичной загрузки 30 000 товаров это кратно меньше трафика → быстрее старт. + private static HttpClientHandler NewHandler() => new() + { + AutomaticDecompression = System.Net.DecompressionMethods.GZip + | System.Net.DecompressionMethods.Deflate | System.Net.DecompressionMethods.Brotli, + }; } /// Подставляет Bearer-токен в каждый запрос. diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index f887872..cf4cac7 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.IO; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using foodmarket.Pos.Core.Auth; @@ -64,6 +65,31 @@ private void RefreshNegatives() + "\n\nПроведите инвентаризацию по этим товарам."; } + // ── Индикатор фоновой синхронизации («обновлено N мин назад / обновляется…») ─ + // Касса работает из локальной базы сразу; синк идёт в фоне и НЕ блокирует UI. + [ObservableProperty] private bool _isSyncing; + [ObservableProperty] private DateTime? _lastSyncAt; + /// Каталог пуст (самая первая установка) — тогда до первого синка + /// продавать нечего; показываем ненавязчивый прогресс (не модалку). + [ObservableProperty] private bool _catalogEmpty; + public bool IsFirstLoad => CatalogEmpty && IsSyncing; + public string SyncBadge => IsSyncing ? "обновляется…" : LastSyncText; + private string LastSyncText + { + get + { + if (LastSyncAt is not { } t) return "ещё не синхронизировано"; + var mins = (int)Math.Floor((DateTime.UtcNow - t).TotalMinutes); + if (mins <= 0) return "обновлено только что"; + if (mins < 60) return $"обновлено {mins} мин назад"; + var hours = mins / 60; + return hours < 24 ? $"обновлено {hours} ч назад" : $"обновлено {hours / 24} дн назад"; + } + } + partial void OnIsSyncingChanged(bool value) { OnPropertyChanged(nameof(SyncBadge)); OnPropertyChanged(nameof(IsFirstLoad)); } + partial void OnLastSyncAtChanged(DateTime? value) => OnPropertyChanged(nameof(SyncBadge)); + partial void OnCatalogEmptyChanged(bool value) => OnPropertyChanged(nameof(IsFirstLoad)); + public string NetBadge => IsOnline ? "На связи" : "Нет связи"; public string ShiftBadge => IsShiftOpen ? (BadgesCompact ? "Смена открыта" : $"Смена {ShiftNumber}") @@ -76,7 +102,7 @@ private void RefreshNegatives() 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"; NegativeCount = 3; NegativeListText = "• Сахар-песок 1кг: -2 шт\n• Молоко 2.5% 1л: -1 шт\n• Хлеб «Семейный»: -4 шт\n\nПроведите инвентаризацию по этим товарам."; } + 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Проведите инвентаризацию по этим товарам."; LastSyncAt = DateTime.UtcNow.AddMinutes(-3); } public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); } @@ -91,6 +117,11 @@ private void StartSession() CashierName = _svc!.Session?.CashierName ?? "кассир"; QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); + // Касса готова к работе СРАЗУ из локальной базы. Отмечаем, пуст ли каталог + // (первая установка) и когда синхронизировались в прошлый раз — всё из + // локального SQLite, без сети. + CatalogEmpty = _svc.Store.ProductCount() == 0; + LastSyncAt = _svc.Store.LastSync(); _ = ResolvePointNameAsync(); _sale = new SaleViewModel(_svc) { @@ -199,17 +230,42 @@ private void StartSyncLoop() _ = SyncTickAsync(); } + private volatile bool _syncRunning; + + /// Фоновый тик синхронизации. Тяжёлую работу (HTTP + запись каталога + /// в SQLite, в т.ч. первичные 30 000 строк) выполняем на пуле потоков через + /// Task.Run — UI-поток НИКОГДА не ждёт синк. Обновления полей маршалим обратно + /// на UI-поток. Перекрытие тиков исключаем флагом (медленная сеть + таймер 15с). private async Task SyncTickAsync() { - if (_svc?.Sync is null) return; + if (_svc?.Sync is null || _syncRunning) return; + _syncRunning = true; + await Dispatcher.UIThread.InvokeAsync(() => IsSyncing = true); + bool online; try { - await _svc.Sync.PushSalesAsync(); - await _svc.Sync.PullCatalogAsync(); - IsOnline = true; + await Task.Run(async () => + { + await _svc.Sync.PushSalesAsync(); + await _svc.Sync.PullCatalogAsync(); + }); + online = true; } - catch { IsOnline = false; } - finally { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); } + catch { online = false; } + finally { _syncRunning = false; } + + await Dispatcher.UIThread.InvokeAsync(() => + { + IsOnline = online; + QueueCount = _svc.Store.PendingCount(); + RefreshNegatives(); + if (online) + { + LastSyncAt = _svc.Store.LastSync(); + CatalogEmpty = _svc.Store.ProductCount() == 0; + } + IsSyncing = false; + }); } } diff --git a/src/food-market.pos/Views/MainWindow.axaml b/src/food-market.pos/Views/MainWindow.axaml index 5527c2f..21bb82e 100644 --- a/src/food-market.pos/Views/MainWindow.axaml +++ b/src/food-market.pos/Views/MainWindow.axaml @@ -68,6 +68,9 @@ + + @@ -75,7 +78,19 @@ - + + + + + + + + + + diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs index dd52016..7845e55 100644 --- a/src/food-market.shared/Pos/V1/SyncDtos.cs +++ b/src/food-market.shared/Pos/V1/SyncDtos.cs @@ -94,6 +94,13 @@ public record PosSyncResponse public required IReadOnlyList Stocks { get; init; } public required IReadOnlyList Counterparties { get; init; } public required IReadOnlyList DeletedProductIds { get; init; } + + /// true — это ПОЛНЫЙ снимок ненулевых остатков + /// (первичная загрузка, since отсутствует): касса заменяет весь локальный снимок. + /// false — ДЕЛЬТА остатков с прошлого синка (изменённые с момента since, включая + /// ушедшие в 0): касса применяет построчно — ненулевые upsert, нулевые удаляет. + /// Так рекуррентный тик не переписывает все 30 000 строк каждый раз. + public bool StocksFullSnapshot { get; init; } } /// Одна продажа из оффлайн-кассы. POS пробил чек локально, теперь diff --git a/tests/food-market.IntegrationTests/PosSyncTests.cs b/tests/food-market.IntegrationTests/PosSyncTests.cs index 4709282..f9fda95 100644 --- a/tests/food-market.IntegrationTests/PosSyncTests.cs +++ b/tests/food-market.IntegrationTests/PosSyncTests.cs @@ -185,13 +185,15 @@ public async Task ClientSaleId_idempotency_across_batches() } [Fact] - public async Task Insufficient_stock_goes_to_failed_not_accepted() + public async Task Oversell_on_pos_is_accepted_and_creates_negative_alert() { + // Решение владельца 08.10: продажа в минус РАЗРЕШЕНА. Касса проводит чек + // даже сверх остатка, остаток уходит в минус, создаётся предупреждение. var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); - // Не подкармливаем — остаток 0. + // Не подкармливаем — остаток 0, продаём 3 → уйдём в −3. var batch = new PosSaleBatchDto { @@ -201,9 +203,12 @@ public async Task Insufficient_stock_goes_to_failed_not_accepted() using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch); post.EnsureSuccessStatusCode(); var resp = await post.Content.ReadFromJsonAsync(); - resp!.Accepted.Should().BeEmpty(); - resp.Failed.Should().HaveCount(1); - resp.Failed[0].Error.Should().Contain("Недостаточный остаток"); + resp!.Accepted.Should().HaveCount(1, "продажа в минус разрешена"); + resp.Failed.Should().BeEmpty(); + + (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(-3m); + var cnt = await api.GetJsonAsync("/api/negative-stock/count"); + cnt.GetProperty("total").GetInt32().Should().BeGreaterThanOrEqualTo(1, "создано предупреждение о минусе"); } [Fact] diff --git a/tests/food-market.UnitTests/PosCoreTests.cs b/tests/food-market.UnitTests/PosCoreTests.cs index bf12203..7b0ba15 100644 --- a/tests/food-market.UnitTests/PosCoreTests.cs +++ b/tests/food-market.UnitTests/PosCoreTests.cs @@ -159,6 +159,15 @@ private static PosSyncResponse Sync(params (Guid id, string name, string bc, dec 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(), + StocksFullSnapshot = true, + Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), + }; + + private static PosSyncResponse StockDelta((Guid id, decimal qty)[] stocks) => new() + { + ServerTime = DateTime.UtcNow, Products = Array.Empty(), Prices = Array.Empty(), + Stocks = stocks.Select(x => new StockSyncDto { ProductId = x.id, StoreId = Guid.NewGuid(), Quantity = x.qty, AsOf = DateTime.UtcNow }).ToList(), + StocksFullSnapshot = false, Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), }; @@ -188,12 +197,30 @@ public void Stocks_are_full_snapshot_replaced_each_sync() 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. + // Следующий ПОЛНЫЙ снимок: 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.GetStock(a).Should().BeNull("A вернулся к 0 → выпал из полного снимка остатков"); s.NegativeCount().Should().Be(1, "в минусе остался только B"); } + [Fact] + public void Stock_delta_upserts_nonzero_and_removes_zeroed_without_touching_others() + { + using var s = PosStore.InMemory(); + var a = Guid.NewGuid(); var b = Guid.NewGuid(); var c = Guid.NewGuid(); + // Полный снимок: три остатка. + s.ApplySync(SyncWithStocks( + new[] { (a, "A", 10m, 1), (b, "B", 10m, 1), (c, "C", 10m, 1) }, + new[] { (a, 5m), (b, 3m), (c, 7m) })); + s.GetStock(a).Should().Be(5m); + + // ДЕЛЬТА: A изменился на 2, B ушёл в 0 (удалить), C не прислан (не трогаем). + s.ApplySync(StockDelta(new[] { (a, 2m), (b, 0m) })); + s.GetStock(a).Should().Be(2m, "A обновлён дельтой"); + s.GetStock(b).Should().BeNull("B ушёл в 0 → удалён"); + s.GetStock(c).Should().Be(7m, "C не менялся и не перезаписывался дельтой"); + } + [Fact] public void Catalog_find_by_barcode_and_name() {