perf(pos): мгновенный старт — gzip-каталог, дельта остатков, синк вне UI-потока

Касса работает из локальной базы сразу; вся синхронизация — в фоне, 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 04:59:01 +05:00
parent 0dc8703712
commit 63da7a9334
8 changed files with 186 additions and 36 deletions

View file

@ -109,7 +109,16 @@ orderby l.SortOrder
if (resolvedStore is null) if (resolvedStore is null)
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" }); 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; var serverTime = DateTime.UtcNow;
// Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt // Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt
@ -168,17 +177,23 @@ orderby l.SortOrder
UpdatedAt = stamp, UpdatedAt = stamp,
}).ToListAsync(ct); }).ToListAsync(ct);
// Остатки: всегда полный снимок на момент ответа. since-фильтр не // Остатки. Первичная загрузка (since отсутствует) → ПОЛНЫЙ снимок ненулевых.
// применяется к остаткам — кассе всегда нужен актуальный stock. // Дельта → только изменённые с момента since (включая ушедшие в 0, чтобы
var stocks = await (from s in _db.Stocks.AsNoTracking() // касса их удалила). Так рекуррентный тик не гоняет все 30 000 строк.
where s.StoreId == resolvedStore.Value && s.Quantity != 0m var stocksFull = since is null;
select new StockSyncDto IQueryable<Domain.Inventory.Stock> stockQuery = _db.Stocks.AsNoTracking()
{ .Where(s => s.StoreId == resolvedStore.Value);
ProductId = s.ProductId, stockQuery = stocksFull
StoreId = s.StoreId, ? stockQuery.Where(s => s.Quantity != 0m)
Quantity = s.Quantity, : stockQuery.Where(s => (s.UpdatedAt ?? s.CreatedAt) > threshold);
AsOf = serverTime, var stocks = await stockQuery
}).ToListAsync(ct); .Select(s => new StockSyncDto
{
ProductId = s.ProductId,
StoreId = s.StoreId,
Quantity = s.Quantity,
AsOf = serverTime,
}).ToListAsync(ct);
// Контрагенты-покупатели. Поставщиков на POS не шлём. // Контрагенты-покупатели. Поставщиков на POS не шлём.
var counterparties = await (from c in _db.Counterparties.AsNoTracking() var counterparties = await (from c in _db.Counterparties.AsNoTracking()
@ -203,6 +218,7 @@ orderby l.SortOrder
Products = productDtos, Products = productDtos,
Prices = prices, Prices = prices,
Stocks = stocks, Stocks = stocks,
StocksFullSnapshot = stocksFull,
Counterparties = counterparties, Counterparties = counterparties,
DeletedProductIds = Array.Empty<Guid>(), DeletedProductIds = Array.Empty<Guid>(),
}); });

View file

@ -100,10 +100,13 @@ public void ApplySync(PosSyncResponse sync)
} }
} }
up.Dispose(); delBc.Dispose(); insBc.Dispose(); up.Dispose(); delBc.Dispose(); insBc.Dispose();
// цены для товаров, пришедших без карточки (только апдейт цены) // Цены для товаров, пришедших БЕЗ карточки (дельта цены без изменения товара).
// ВАЖНО: product-id в HashSet (O(1)), иначе `Products.Any(...)` на строку даёт
// O(n²) — на 30 000 товаров это ~900 млн сравнений и +секунды к записи.
var productIdSet = new HashSet<Guid>(sync.Products.Select(p => p.Id));
foreach (var kv in priceByProduct) 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; using var cmd = _cn.CreateCommand(); cmd.Transaction = tx;
cmd.CommandText = "UPDATE products SET Price=$pr WHERE Id=$id"; 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()); 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; 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, // Дельта → применяем построчно: ненулевые upsert, нулевые (вернулись к 0)
// выпадает из снимка → строка удаляется → GetStock вернёт null (=0, не минус). // удаляем. Так рекуррентный тик не трогает все 30 000 строк каждый раз.
using (var delStock = _cn.CreateCommand()) if (sync.StocksFullSnapshot)
{ {
using var delStock = _cn.CreateCommand();
delStock.Transaction = tx; delStock.CommandText = "DELETE FROM stock"; delStock.ExecuteNonQuery(); delStock.Transaction = tx; delStock.CommandText = "DELETE FROM stock"; delStock.ExecuteNonQuery();
} }
if (sync.Stocks.Count > 0) if (sync.Stocks.Count > 0)
@ -126,14 +130,24 @@ public void ApplySync(PosSyncResponse sync)
var insSt = _cn.CreateCommand(); insSt.Transaction = tx; 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"; 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 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) 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["$id"].Value = s.ProductId.ToString();
stP["$q"].Value = s.Quantity.ToString(System.Globalization.CultureInfo.InvariantCulture); stP["$q"].Value = s.Quantity.ToString(System.Globalization.CultureInfo.InvariantCulture);
stP["$ao"].Value = s.AsOf.ToString("O"); stP["$ao"].Value = s.AsOf.ToString("O");
insSt.ExecuteNonQuery(); insSt.ExecuteNonQuery();
} }
insSt.Dispose(); insSt.Dispose(); delSt.Dispose();
} }
tx.Commit(); tx.Commit();
SetKv("last_sync", sync.ServerTime.ToString("O")); SetKv("last_sync", sync.ServerTime.ToString("O"));

View file

@ -34,7 +34,7 @@ public AppServices()
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos"); var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos");
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
Store = new PosStore(Path.Combine(dir, "catalog.db")); 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); Auth = new AuthService(Http);
// Windows — DPAPI (привязка к учётке), иначе файл с правами 0600. // Windows — DPAPI (привязка к учётке), иначе файл с правами 0600.
Tokens = OperatingSystem.IsWindows() Tokens = OperatingSystem.IsWindows()
@ -58,7 +58,7 @@ public AppServices(PosStore store)
public void ConfigureApi(PosSession session) public void ConfigureApi(PosSession session)
{ {
Session = 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('/')), BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30), Timeout = TimeSpan.FromSeconds(30),
@ -66,6 +66,16 @@ public void ConfigureApi(PosSession session)
Api = RestService.For<IServerApi>(client); Api = RestService.For<IServerApi>(client);
Sync = new SyncService(Store, Api); Sync = new SyncService(Store, Api);
} }
/// <summary>HTTP-handler с автоматической декомпрессией (gzip/deflate/brotli):
/// сервер жмёт JSON каталога (Program.cs AddResponseCompression), но без этого
/// флага HttpClient не шлёт Accept-Encoding и каталог едет несжатым. Для
/// первичной загрузки 30 000 товаров это кратно меньше трафика → быстрее старт.</summary>
private static HttpClientHandler NewHandler() => new()
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip
| System.Net.DecompressionMethods.Deflate | System.Net.DecompressionMethods.Brotli,
};
} }
/// <summary>Подставляет Bearer-токен в каждый запрос.</summary> /// <summary>Подставляет Bearer-токен в каждый запрос.</summary>

View file

@ -1,5 +1,6 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using foodmarket.Pos.Core.Auth; using foodmarket.Pos.Core.Auth;
@ -64,6 +65,31 @@ private void RefreshNegatives()
+ "\n\nПроведите инвентаризацию по этим товарам."; + "\n\nПроведите инвентаризацию по этим товарам.";
} }
// ── Индикатор фоновой синхронизации («обновлено N мин назад / обновляется…») ─
// Касса работает из локальной базы сразу; синк идёт в фоне и НЕ блокирует UI.
[ObservableProperty] private bool _isSyncing;
[ObservableProperty] private DateTime? _lastSyncAt;
/// <summary>Каталог пуст (самая первая установка) — тогда до первого синка
/// продавать нечего; показываем ненавязчивый прогресс (не модалку).</summary>
[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 NetBadge => IsOnline ? "На связи" : "Нет связи";
public string ShiftBadge => IsShiftOpen public string ShiftBadge => IsShiftOpen
? (BadgesCompact ? "Смена открыта" : $"Смена {ShiftNumber}") ? (BadgesCompact ? "Смена открыта" : $"Смена {ShiftNumber}")
@ -76,7 +102,7 @@ private void RefreshNegatives()
partial void OnBadgesCompactChanged(bool value) { OnPropertyChanged(nameof(ShiftBadge)); OnPropertyChanged(nameof(QueueBadge)); OnPropertyChanged(nameof(NegativeBadge)); } 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"; 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(); } 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 ?? "кассир"; CashierName = _svc!.Session?.CashierName ?? "кассир";
QueueCount = _svc.Store.PendingCount(); QueueCount = _svc.Store.PendingCount();
RefreshNegatives(); RefreshNegatives();
// Касса готова к работе СРАЗУ из локальной базы. Отмечаем, пуст ли каталог
// (первая установка) и когда синхронизировались в прошлый раз — всё из
// локального SQLite, без сети.
CatalogEmpty = _svc.Store.ProductCount() == 0;
LastSyncAt = _svc.Store.LastSync();
_ = ResolvePointNameAsync(); _ = ResolvePointNameAsync();
_sale = new SaleViewModel(_svc) _sale = new SaleViewModel(_svc)
{ {
@ -199,17 +230,42 @@ private void StartSyncLoop()
_ = SyncTickAsync(); _ = SyncTickAsync();
} }
private volatile bool _syncRunning;
/// <summary>Фоновый тик синхронизации. Тяжёлую работу (HTTP + запись каталога
/// в SQLite, в т.ч. первичные 30 000 строк) выполняем на пуле потоков через
/// Task.Run — UI-поток НИКОГДА не ждёт синк. Обновления полей маршалим обратно
/// на UI-поток. Перекрытие тиков исключаем флагом (медленная сеть + таймер 15с).</summary>
private async Task SyncTickAsync() 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 try
{ {
await _svc.Sync.PushSalesAsync(); await Task.Run(async () =>
await _svc.Sync.PullCatalogAsync(); {
IsOnline = true; await _svc.Sync.PushSalesAsync();
await _svc.Sync.PullCatalogAsync();
});
online = true;
} }
catch { IsOnline = false; } catch { online = false; }
finally { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); } 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;
});
} }
} }

View file

@ -68,6 +68,9 @@
<Ellipse Width="8" Height="8" VerticalAlignment="Center" Fill="{DynamicResource PosBrand}"/> <Ellipse Width="8" Height="8" VerticalAlignment="Center" Fill="{DynamicResource PosBrand}"/>
<TextBlock Text="{Binding NetBadge}"/> <TextBlock Text="{Binding NetBadge}"/>
<TextBlock Text="{Binding QueueBadge}" IsVisible="{Binding QueueBadge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> <TextBlock Text="{Binding QueueBadge}" IsVisible="{Binding QueueBadge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<!-- Фоновая синхронизация: «обновлено N мин назад» / «обновляется…».
Касса работает из локальной базы, этот индикатор лишь информирует. -->
<TextBlock Text="{Binding SyncBadge}" Foreground="{DynamicResource PosTextMuted}"/>
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>
@ -75,7 +78,19 @@
</Border> </Border>
<!-- Текущая страница --> <!-- Текущая страница -->
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}"/> <Grid Grid.Row="1">
<ContentControl Content="{Binding CurrentPage}"/>
<!-- Первичная установка: каталог ещё не загружен. Ненавязчивая полоса
сверху (НЕ модалка) — обычные тёплые старты её не показывают. -->
<Border VerticalAlignment="Top" Background="#FEF3C7" BorderBrush="#FCD34D" BorderThickness="0,0,0,1"
Padding="24,10" IsVisible="{Binding IsFirstLoad}">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Text="⏳" FontSize="15" VerticalAlignment="Center"/>
<TextBlock Text="Первая загрузка каталога — касса будет готова через несколько секунд. Это разовая операция."
Foreground="#92400E" FontSize="14" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
<!-- Полоса горячих клавиш --> <!-- Полоса горячих клавиш -->
<Border Grid.Row="2" Background="{DynamicResource PosCard}" BorderBrush="{DynamicResource PosBorder}" BorderThickness="0,1,0,0" Padding="24,0"> <Border Grid.Row="2" Background="{DynamicResource PosCard}" BorderBrush="{DynamicResource PosBorder}" BorderThickness="0,1,0,0" Padding="24,0">

View file

@ -94,6 +94,13 @@ public record PosSyncResponse
public required IReadOnlyList<StockSyncDto> Stocks { get; init; } public required IReadOnlyList<StockSyncDto> Stocks { get; init; }
public required IReadOnlyList<CounterpartySyncDto> Counterparties { get; init; } public required IReadOnlyList<CounterpartySyncDto> Counterparties { get; init; }
public required IReadOnlyList<Guid> DeletedProductIds { get; init; } public required IReadOnlyList<Guid> DeletedProductIds { get; init; }
/// <summary>true — <see cref="Stocks"/> это ПОЛНЫЙ снимок ненулевых остатков
/// (первичная загрузка, since отсутствует): касса заменяет весь локальный снимок.
/// false — ДЕЛЬТА остатков с прошлого синка (изменённые с момента since, включая
/// ушедшие в 0): касса применяет построчно — ненулевые upsert, нулевые удаляет.
/// Так рекуррентный тик не переписывает все 30 000 строк каждый раз.</summary>
public bool StocksFullSnapshot { get; init; }
} }
/// <summary>Одна продажа из оффлайн-кассы. POS пробил чек локально, теперь /// <summary>Одна продажа из оффлайн-кассы. POS пробил чек локально, теперь

View file

@ -185,13 +185,15 @@ public async Task ClientSaleId_idempotency_across_batches()
} }
[Fact] [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()); var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}"); await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync(); var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Не подкармливаем — остаток 0. // Не подкармливаем — остаток 0, продаём 3 → уйдём в −3.
var batch = new PosSaleBatchDto 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); using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.EnsureSuccessStatusCode(); post.EnsureSuccessStatusCode();
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>(); var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
resp!.Accepted.Should().BeEmpty(); resp!.Accepted.Should().HaveCount(1, "продажа в минус разрешена");
resp.Failed.Should().HaveCount(1); resp.Failed.Should().BeEmpty();
resp.Failed[0].Error.Should().Contain("Недостаточный остаток");
(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] [Fact]

View file

@ -159,6 +159,15 @@ private static PosSyncResponse Sync(params (Guid id, string name, string bc, dec
Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow, Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow,
}).ToList(), }).ToList(),
Stocks = stocks.Select(x => new StockSyncDto { ProductId = x.id, StoreId = Guid.NewGuid(), Quantity = x.qty, AsOf = 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<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
};
private static PosSyncResponse StockDelta((Guid id, decimal qty)[] stocks) => new()
{
ServerTime = DateTime.UtcNow, Products = Array.Empty<ProductSyncDto>(), Prices = Array.Empty<PriceSyncDto>(),
Stocks = stocks.Select(x => new StockSyncDto { ProductId = x.id, StoreId = Guid.NewGuid(), Quantity = x.qty, AsOf = DateTime.UtcNow }).ToList(),
StocksFullSnapshot = false,
Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
}; };
@ -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.ApplySync(SyncWithStocks(new[] { (a, "A", 10m, 1), (b, "B", 10m, 1) }, new[] { (a, -3m), (b, -1m) }));
s.NegativeCount().Should().Be(2); 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.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"); 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] [Fact]
public void Catalog_find_by_barcode_and_name() public void Catalog_find_by_barcode_and_name()
{ {