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:
parent
0dc8703712
commit
63da7a9334
|
|
@ -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>(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 пробил чек локально, теперь
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue