Compare commits

..

No commits in common. "e78f14bc6b4fcc11ce3c975a2ef3063b3bc3ea4a" and "0dc8703712dc35af2163ed6e9cb2013b3952e6d0" have entirely different histories.

13 changed files with 38 additions and 364 deletions

View file

@ -34,19 +34,17 @@ public class DemoSeedController : ControllerBase
private readonly DemoTenantSeeder _shortSeeder; private readonly DemoTenantSeeder _shortSeeder;
private readonly YearDemoSeeder _yearSeeder; private readonly YearDemoSeeder _yearSeeder;
private readonly ShowcaseSeeder _showcaseSeeder; private readonly ShowcaseSeeder _showcaseSeeder;
private readonly BulkCatalogSeeder _bulkSeeder;
private readonly ITenantContext _tenant; private readonly ITenantContext _tenant;
private readonly ILogger<DemoSeedController> _log; private readonly ILogger<DemoSeedController> _log;
public DemoSeedController( public DemoSeedController(
DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder, DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder,
ShowcaseSeeder showcaseSeeder, BulkCatalogSeeder bulkSeeder, ShowcaseSeeder showcaseSeeder,
ITenantContext tenant, ILogger<DemoSeedController> log) ITenantContext tenant, ILogger<DemoSeedController> log)
{ {
_shortSeeder = shortSeeder; _shortSeeder = shortSeeder;
_yearSeeder = yearSeeder; _yearSeeder = yearSeeder;
_showcaseSeeder = showcaseSeeder; _showcaseSeeder = showcaseSeeder;
_bulkSeeder = bulkSeeder;
_tenant = tenant; _tenant = tenant;
_log = log; _log = log;
} }
@ -70,16 +68,10 @@ public async Task<ActionResult<object>> Status([FromQuery] int years = 0, Cancel
/// возвращает existing summary без вставок. При <c>?years=1</c> — Year-demo /// возвращает existing summary без вставок. При <c>?years=1</c> — Year-demo
/// (см. <see cref="YearDemoSeeder"/>): 200 товаров / 1500 продаж / год.</summary> /// (см. <see cref="YearDemoSeeder"/>): 200 товаров / 1500 продаж / год.</summary>
[HttpPost] [HttpPost]
public async Task<ActionResult<object>> Run([FromQuery] int years = 0, [FromQuery] int showcase = 0, [FromQuery] int bulk = 0, CancellationToken ct = default) public async Task<ActionResult<object>> Run([FromQuery] int years = 0, [FromQuery] int showcase = 0, CancellationToken ct = default)
{ {
var orgId = _tenant.OrganizationId var orgId = _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant in context"); ?? throw new InvalidOperationException("No tenant in context");
if (bulk >= 1)
{
_log.LogInformation("Bulk catalog seed requested for org={OrgId}: {Bulk} products", orgId, bulk);
var result = await _bulkSeeder.SeedAsync(orgId, bulk, ct);
return Ok(result);
}
if (showcase >= 1) if (showcase >= 1)
{ {
_log.LogInformation("Showcase seed requested for org={OrgId}", orgId); _log.LogInformation("Showcase seed requested for org={OrgId}", orgId);

View file

@ -109,16 +109,7 @@ orderby l.SortOrder
if (resolvedStore is null) if (resolvedStore is null)
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" }); return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
// `since` приходит из query-строки и может иметь Kind=Unspecified/Local — var threshold = since ?? DateTime.MinValue.ToUniversalTime();
// 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
@ -177,17 +168,11 @@ orderby l.SortOrder
UpdatedAt = stamp, UpdatedAt = stamp,
}).ToListAsync(ct); }).ToListAsync(ct);
// Остатки. Первичная загрузка (since отсутствует) → ПОЛНЫЙ снимок ненулевых. // Остатки: всегда полный снимок на момент ответа. since-фильтр не
// Дельта → только изменённые с момента since (включая ушедшие в 0, чтобы // применяется к остаткам — кассе всегда нужен актуальный stock.
// касса их удалила). Так рекуррентный тик не гоняет все 30 000 строк. var stocks = await (from s in _db.Stocks.AsNoTracking()
var stocksFull = since is null; where s.StoreId == resolvedStore.Value && s.Quantity != 0m
IQueryable<Domain.Inventory.Stock> stockQuery = _db.Stocks.AsNoTracking() select new StockSyncDto
.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, ProductId = s.ProductId,
StoreId = s.StoreId, StoreId = s.StoreId,
@ -218,7 +203,6 @@ 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

@ -527,7 +527,6 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>(); builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
// Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1). // Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1).
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>(); builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
builder.Services.AddScoped<foodmarket.Api.Seed.BulkCatalogSeeder>();
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>(); builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad. // DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
// Keep the file as reference for anyone starting without MoySklad access — // Keep the file as reference for anyone starting without MoySklad access —

View file

@ -1,74 +0,0 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Seed;
/// <summary>Массовое наполнение каталога для нагрузочных замеров старта кассы
/// (цель — ~30 000 товаров, как у «Кассы МойСклад»). Каждый товар: имя, артикул,
/// 1 штрихкод EAN-13, 1 розничная цена, остаток на главном складе. Вставка
/// батчами в транзакциях. Идемпотентность — по маркеру артикула <c>BULK-</c>:
/// повторный вызов досоздаёт недостающие до целевого количества.</summary>
public sealed class BulkCatalogSeeder
{
private readonly AppDbContext _db;
public BulkCatalogSeeder(AppDbContext db) => _db = db;
public async Task<object> SeedAsync(Guid orgId, int target, CancellationToken ct = default)
{
target = Math.Clamp(target, 1, 60_000);
var unit = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "796", ct);
var priceType = await _db.PriceTypes.IgnoreQueryFilters().FirstAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
var currency = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
var store = await _db.Stores.IgnoreQueryFilters()
.Where(s => s.OrganizationId == orgId).OrderByDescending(s => s.IsMain).FirstAsync(ct);
var existing = await _db.Products.IgnoreQueryFilters()
.CountAsync(p => p.OrganizationId == orgId && p.Article!.StartsWith("BULK-"), ct);
var toCreate = target - existing;
if (toCreate <= 0)
return new { alreadySeeded = true, bulkProducts = existing, target };
long barcodeSeq = 460_000_000_000L + existing;
const int batch = 2000;
for (int start = existing; start < target; start += batch)
{
var end = Math.Min(start + batch, target);
for (int i = start; i < end; i++)
{
var pid = Guid.NewGuid();
_db.Products.Add(new Product
{
Id = pid, OrganizationId = orgId,
Name = $"Товар демонстрационный №{i + 1:D5}",
Article = $"BULK-{i + 1:D6}",
UnitOfMeasureId = unit.Id, Vat = 12m, VatEnabled = true,
Packaging = Packaging.Piece, ReferencePrice = 100m + (i % 900),
Prices = new List<ProductPrice>
{
new() { OrganizationId = orgId, PriceTypeId = priceType.Id, Amount = 100m + (i % 900), CurrencyId = currency.Id },
},
Barcodes = new List<ProductBarcode>
{
new() { OrganizationId = orgId, Code = MakeEan13(barcodeSeq++), Type = BarcodeType.Ean13, IsPrimary = true },
},
});
_db.Stocks.Add(new Stock { OrganizationId = orgId, ProductId = pid, StoreId = store.Id, Quantity = 100m });
}
await _db.SaveChangesAsync(ct);
_db.ChangeTracker.Clear();
}
var total = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == orgId, ct);
return new { alreadySeeded = false, created = toCreate, bulkProducts = target, totalProducts = total };
}
private static string MakeEan13(long body12)
{
var s = (body12 % 1_000_000_000_000L).ToString("D12");
var sum = 0;
for (int i = 0; i < 12; i++) { var d = s[i] - '0'; sum += i % 2 == 0 ? d : d * 3; }
return s + ((10 - sum % 10) % 10);
}
}

View file

@ -100,13 +100,10 @@ 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 (productIdSet.Contains(kv.Key)) continue; if (sync.Products.Any(x => x.Id == 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());
@ -117,12 +114,11 @@ 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();
} }
// Остатки. Полный снимок (первичная загрузка) → перезаписываем целиком. // Остатки — ПОЛНЫЙ снимок ненулевых остатков склада (сервер шлёт всё каждый
// Дельта → применяем построчно: ненулевые upsert, нулевые (вернулись к 0) // раз, не дельту). Поэтому перезаписываем целиком: товар, вернувшийся к 0,
// удаляем. Так рекуррентный тик не трогает все 30 000 строк каждый раз. // выпадает из снимка → строка удаляется → GetStock вернёт null (=0, не минус).
if (sync.StocksFullSnapshot) using (var delStock = _cn.CreateCommand())
{ {
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)
@ -130,24 +126,14 @@ 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(); delSt.Dispose(); insSt.Dispose();
} }
tx.Commit(); tx.Commit();
SetKv("last_sync", sync.ServerTime.ToString("O")); SetKv("last_sync", sync.ServerTime.ToString("O"));

View file

@ -18,7 +18,6 @@ public static int Main(string[] args)
if (args.Contains("--compare")) return CompareRunner.Run(args); if (args.Contains("--compare")) return CompareRunner.Run(args);
if (args.Contains("--density")) return ScreenshotRunner.RunDensity(args); if (args.Contains("--density")) return ScreenshotRunner.RunDensity(args);
if (args.Contains("--uitest")) return UiTestRunner.Run(args); if (args.Contains("--uitest")) return UiTestRunner.Run(args);
if (args.Contains("--bench")) return BenchRunner.Run(args);
// --demo : показать реальное окно с примерами данных (для скриншота на Windows). // --demo : показать реальное окно с примерами данных (для скриншота на Windows).
if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true; if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true;

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(NewHandler()) { Timeout = TimeSpan.FromSeconds(30) }; Http = new HttpClient { 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, NewHandler())) var client = new HttpClient(new AuthHeaderHandler(() => Session?.AccessToken, new HttpClientHandler()))
{ {
BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')), BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30), Timeout = TimeSpan.FromSeconds(30),
@ -66,16 +66,6 @@ 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,92 +0,0 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Sync;
using Refit;
namespace foodmarket.Pos.Tooling;
/// <summary>Замеры старта/синхронизации кассы против живого сервера.
/// Запуск: <c>dotnet run -- --bench &lt;serverUrl&gt; &lt;user&gt; &lt;password&gt;</c>.
///
/// Мерит: (1) первичную полную загрузку каталога (HTTP+десериализация отдельно
/// от записи в SQLite), (2) тёплый старт — открытие локальной базы + поиск,
/// (3) дельта-синк (изменений ~нет). Всё — с включённой gzip-декомпрессией.</summary>
public static class BenchRunner
{
public static int Run(string[] args)
{
var rest = args.SkipWhile(a => a != "--bench").Skip(1).ToArray();
if (rest.Length < 3) { Console.Error.WriteLine("usage: --bench <serverUrl> <user> <password>"); return 2; }
return RunAsync(rest[0].TrimEnd('/'), rest[1], rest[2]).GetAwaiter().GetResult();
}
private static async Task<int> RunAsync(string url, string user, string pass)
{
var sw = Stopwatch.StartNew();
var auth = new AuthService(new HttpClient());
var tok = await auth.LoginAsync(url, user, pass);
Console.WriteLine($"[bench] login: {sw.ElapsedMilliseconds} ms");
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli };
var http = new HttpClient(new BearerHandler(tok.AccessToken, handler)) { BaseAddress = new Uri(url), Timeout = TimeSpan.FromSeconds(120) };
var api = RestService.For<IServerApi>(http);
var dbFile = Path.Combine(Path.GetTempPath(), $"pos-bench-{Guid.NewGuid():N}.db");
try
{
// 1. Первичная полная загрузка: сеть+десериализация, затем запись.
var store = new PosStore(dbFile);
sw.Restart();
var full = await api.SyncAsync(null);
var netMs = sw.ElapsedMilliseconds;
sw.Restart();
store.ApplySync(full);
var applyMs = sw.ElapsedMilliseconds;
Console.WriteLine($"[bench] ПЕРВИЧНАЯ загрузка: товаров={full.Products.Count} цен={full.Prices.Count} остатков={full.Stocks.Count}");
Console.WriteLine($"[bench] сеть+десериализация: {netMs} ms; запись в SQLite: {applyMs} ms; ИТОГО до продажи: {netMs + applyMs} ms");
Console.WriteLine($"[bench] в базе товаров: {store.ProductCount()}");
store.Dispose();
// 2. Тёплый старт: открыть существующую базу + поиск по штрихкоду и имени.
sw.Restart();
var warm = new PosStore(dbFile);
var openMs = sw.ElapsedMilliseconds;
sw.Restart();
var cnt = warm.ProductCount();
var firstBc = full.Products.FirstOrDefault()?.Barcodes.FirstOrDefault();
if (firstBc is not null) _ = warm.FindByBarcode(firstBc);
_ = warm.SearchByName("товар", 50);
var queryMs = sw.ElapsedMilliseconds;
Console.WriteLine($"[bench] ТЁПЛЫЙ старт: открытие базы ({cnt} товаров) {openMs} ms; поиск+COUNT {queryMs} ms; готовность {openMs + queryMs} ms");
// 3. Дельта-синк (с момента full — изменений почти нет).
var sync = new SyncService(warm, api);
warm.SetKv("last_sync", full.ServerTime.ToString("O"));
sw.Restart();
var deltaResp = await api.SyncAsync(full.ServerTime);
var dNet = sw.ElapsedMilliseconds;
sw.Restart();
warm.ApplySync(deltaResp);
var dApply = sw.ElapsedMilliseconds;
Console.WriteLine($"[bench] ДЕЛЬТА (без изменений): товаров={deltaResp.Products.Count} остатков={deltaResp.Stocks.Count}; сеть {dNet} ms; запись {dApply} ms");
warm.Dispose();
return 0;
}
finally { try { File.Delete(dbFile); } catch { } }
}
private sealed class BearerHandler : DelegatingHandler
{
private readonly string _token;
public BearerHandler(string token, HttpMessageHandler inner) : base(inner) { _token = token; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
return base.SendAsync(req, ct);
}
}
}

View file

@ -1,6 +1,5 @@
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;
@ -65,31 +64,6 @@ 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}")
@ -102,7 +76,7 @@ private string LastSyncText
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Проведите инвентаризацию по этим товарам."; LastSyncAt = DateTime.UtcNow.AddMinutes(-3); } public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Виктория Ким"; ShiftNumber = "С-2026-000012 открыта с 09:00"; IsShiftOpen = true; StoreName = "Магазин у дома"; PointName = "Касса 1"; NegativeCount = 3; NegativeListText = "• Сахар-песок 1кг: -2 шт\n• Молоко 2.5% 1л: -1 шт\n• Хлеб «Семейный»: -4 шт\n\nПроведите инвентаризацию по этим товарам."; }
public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); } public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); }
@ -117,11 +91,6 @@ 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)
{ {
@ -230,42 +199,17 @@ 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 || _syncRunning) return; if (_svc?.Sync is null) return;
_syncRunning = true;
await Dispatcher.UIThread.InvokeAsync(() => IsSyncing = true);
bool online;
try try
{
await Task.Run(async () =>
{ {
await _svc.Sync.PushSalesAsync(); await _svc.Sync.PushSalesAsync();
await _svc.Sync.PullCatalogAsync(); await _svc.Sync.PullCatalogAsync();
}); IsOnline = true;
online = true;
} }
catch { online = false; } catch { IsOnline = false; }
finally { _syncRunning = false; } finally { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); }
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,9 +68,6 @@
<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>
@ -78,19 +75,7 @@
</Border> </Border>
<!-- Текущая страница --> <!-- Текущая страница -->
<Grid Grid.Row="1"> <ContentControl Grid.Row="1" Content="{Binding CurrentPage}"/>
<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,13 +94,6 @@ 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,15 +185,13 @@ public async Task ClientSaleId_idempotency_across_batches()
} }
[Fact] [Fact]
public async Task Oversell_on_pos_is_accepted_and_creates_negative_alert() public async Task Insufficient_stock_goes_to_failed_not_accepted()
{ {
// Решение владельца 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, продаём 3 → уйдём в −3. // Не подкармливаем — остаток 0.
var batch = new PosSaleBatchDto var batch = new PosSaleBatchDto
{ {
@ -203,12 +201,9 @@ public async Task Oversell_on_pos_is_accepted_and_creates_negative_alert()
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().HaveCount(1, "продажа в минус разрешена"); resp!.Accepted.Should().BeEmpty();
resp.Failed.Should().BeEmpty(); resp.Failed.Should().HaveCount(1);
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,15 +159,6 @@ 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>(),
}; };
@ -197,30 +188,12 @@ 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()
{ {