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)
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,11 +177,17 @@ 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
// Остатки. Первичная загрузка (since отсутствует) → ПОЛНЫЙ снимок ненулевых.
// Дельта → только изменённые с момента since (включая ушедшие в 0, чтобы
// касса их удалила). Так рекуррентный тик не гоняет все 30 000 строк.
var stocksFull = since is null;
IQueryable<Domain.Inventory.Stock> 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,
@ -203,6 +218,7 @@ orderby l.SortOrder
Products = productDtos,
Prices = prices,
Stocks = stocks,
StocksFullSnapshot = stocksFull,
Counterparties = counterparties,
DeletedProductIds = Array.Empty<Guid>(),
});

View file

@ -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<Guid>(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"));

View file

@ -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<IServerApi>(client);
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>

View file

@ -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;
/// <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 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)); }
/// <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(); }
@ -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;
/// <summary>Фоновый тик синхронизации. Тяжёлую работу (HTTP + запись каталога
/// в SQLite, в т.ч. первичные 30 000 строк) выполняем на пуле потоков через
/// Task.Run — UI-поток НИКОГДА не ждёт синк. Обновления полей маршалим обратно
/// на UI-поток. Перекрытие тиков исключаем флагом (медленная сеть + таймер 15с).</summary>
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 Task.Run(async () =>
{
await _svc.Sync.PushSalesAsync();
await _svc.Sync.PullCatalogAsync();
IsOnline = true;
});
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;
});
}
}

View file

@ -68,6 +68,9 @@
<Ellipse Width="8" Height="8" VerticalAlignment="Center" Fill="{DynamicResource PosBrand}"/>
<TextBlock Text="{Binding NetBadge}"/>
<TextBlock Text="{Binding QueueBadge}" IsVisible="{Binding QueueBadge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<!-- Фоновая синхронизация: «обновлено N мин назад» / «обновляется…».
Касса работает из локальной базы, этот индикатор лишь информирует. -->
<TextBlock Text="{Binding SyncBadge}" Foreground="{DynamicResource PosTextMuted}"/>
</StackPanel>
</Border>
</StackPanel>
@ -75,7 +78,19 @@
</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">

View file

@ -94,6 +94,13 @@ public record PosSyncResponse
public required IReadOnlyList<StockSyncDto> Stocks { get; init; }
public required IReadOnlyList<CounterpartySyncDto> Counterparties { 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 пробил чек локально, теперь

View file

@ -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<PosSaleBatchResponse>();
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]

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,
}).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>(),
};
@ -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()
{