test(pos): инструменты замера старта — bulk-каталог + bench
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
- POST /api/admin/seed-demo?bulk=N — массовое наполнение каталога (до 60k товаров) батчами для нагрузочных замеров (идемпотентно по маркеру BULK-). - POS --bench <url> <user> <pass>: мерит первичную загрузку (сеть/запись отдельно), тёплый старт (открытие базы + поиск), дельта-синк. С gzip-декомпрессией. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
63da7a9334
commit
e78f14bc6b
|
|
@ -34,17 +34,19 @@ 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,
|
ShowcaseSeeder showcaseSeeder, BulkCatalogSeeder bulkSeeder,
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -68,10 +70,16 @@ 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, CancellationToken ct = default)
|
public async Task<ActionResult<object>> Run([FromQuery] int years = 0, [FromQuery] int showcase = 0, [FromQuery] int bulk = 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);
|
||||||
|
|
|
||||||
|
|
@ -527,6 +527,7 @@ [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 —
|
||||||
|
|
|
||||||
74
src/food-market.api/Seed/BulkCatalogSeeder.cs
Normal file
74
src/food-market.api/Seed/BulkCatalogSeeder.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ 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;
|
||||||
|
|
|
||||||
92
src/food-market.pos/Tooling/BenchRunner.cs
Normal file
92
src/food-market.pos/Tooling/BenchRunner.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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 <serverUrl> <user> <password></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue