From e78f14bc6b4fcc11ce3c975a2ef3063b3bc3ea4a Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 04:59:11 +0500 Subject: [PATCH] =?UTF-8?q?test(pos):=20=D0=B8=D0=BD=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B0=20=E2=80=94?= =?UTF-8?q?=20bulk-=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20+=20bench?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/admin/seed-demo?bulk=N — массовое наполнение каталога (до 60k товаров) батчами для нагрузочных замеров (идемпотентно по маркеру BULK-). - POS --bench : мерит первичную загрузку (сеть/запись отдельно), тёплый старт (открытие базы + поиск), дельта-синк. С gzip-декомпрессией. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Admin/DemoSeedController.cs | 12 ++- src/food-market.api/Program.cs | 1 + src/food-market.api/Seed/BulkCatalogSeeder.cs | 74 +++++++++++++++ src/food-market.pos/Program.cs | 1 + src/food-market.pos/Tooling/BenchRunner.cs | 92 +++++++++++++++++++ 5 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/food-market.api/Seed/BulkCatalogSeeder.cs create mode 100644 src/food-market.pos/Tooling/BenchRunner.cs diff --git a/src/food-market.api/Controllers/Admin/DemoSeedController.cs b/src/food-market.api/Controllers/Admin/DemoSeedController.cs index b2da63d..376a1a8 100644 --- a/src/food-market.api/Controllers/Admin/DemoSeedController.cs +++ b/src/food-market.api/Controllers/Admin/DemoSeedController.cs @@ -34,17 +34,19 @@ public class DemoSeedController : ControllerBase private readonly DemoTenantSeeder _shortSeeder; private readonly YearDemoSeeder _yearSeeder; private readonly ShowcaseSeeder _showcaseSeeder; + private readonly BulkCatalogSeeder _bulkSeeder; private readonly ITenantContext _tenant; private readonly ILogger _log; public DemoSeedController( DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder, - ShowcaseSeeder showcaseSeeder, + ShowcaseSeeder showcaseSeeder, BulkCatalogSeeder bulkSeeder, ITenantContext tenant, ILogger log) { _shortSeeder = shortSeeder; _yearSeeder = yearSeeder; _showcaseSeeder = showcaseSeeder; + _bulkSeeder = bulkSeeder; _tenant = tenant; _log = log; } @@ -68,10 +70,16 @@ public async Task> Status([FromQuery] int years = 0, Cancel /// возвращает existing summary без вставок. При ?years=1 — Year-demo /// (см. ): 200 товаров / 1500 продаж / год. [HttpPost] - public async Task> Run([FromQuery] int years = 0, [FromQuery] int showcase = 0, CancellationToken ct = default) + public async Task> Run([FromQuery] int years = 0, [FromQuery] int showcase = 0, [FromQuery] int bulk = 0, CancellationToken ct = default) { var orgId = _tenant.OrganizationId ?? 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) { _log.LogInformation("Showcase seed requested for org={OrgId}", orgId); diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 588b17d..411cf18 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -527,6 +527,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddScoped(); // Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1). builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); // DemoCatalogSeeder disabled: real catalog is imported from MoySklad. // Keep the file as reference for anyone starting without MoySklad access — diff --git a/src/food-market.api/Seed/BulkCatalogSeeder.cs b/src/food-market.api/Seed/BulkCatalogSeeder.cs new file mode 100644 index 0000000..b50823e --- /dev/null +++ b/src/food-market.api/Seed/BulkCatalogSeeder.cs @@ -0,0 +1,74 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Inventory; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Seed; + +/// Массовое наполнение каталога для нагрузочных замеров старта кассы +/// (цель — ~30 000 товаров, как у «Кассы МойСклад»). Каждый товар: имя, артикул, +/// 1 штрихкод EAN-13, 1 розничная цена, остаток на главном складе. Вставка +/// батчами в транзакциях. Идемпотентность — по маркеру артикула BULK-: +/// повторный вызов досоздаёт недостающие до целевого количества. +public sealed class BulkCatalogSeeder +{ + private readonly AppDbContext _db; + public BulkCatalogSeeder(AppDbContext db) => _db = db; + + public async Task 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 + { + new() { OrganizationId = orgId, PriceTypeId = priceType.Id, Amount = 100m + (i % 900), CurrencyId = currency.Id }, + }, + Barcodes = new List + { + 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); + } +} diff --git a/src/food-market.pos/Program.cs b/src/food-market.pos/Program.cs index 6833e99..f8fe576 100644 --- a/src/food-market.pos/Program.cs +++ b/src/food-market.pos/Program.cs @@ -18,6 +18,7 @@ public static int Main(string[] args) if (args.Contains("--compare")) return CompareRunner.Run(args); if (args.Contains("--density")) return ScreenshotRunner.RunDensity(args); if (args.Contains("--uitest")) return UiTestRunner.Run(args); + if (args.Contains("--bench")) return BenchRunner.Run(args); // --demo : показать реальное окно с примерами данных (для скриншота на Windows). if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true; diff --git a/src/food-market.pos/Tooling/BenchRunner.cs b/src/food-market.pos/Tooling/BenchRunner.cs new file mode 100644 index 0000000..7183b4f --- /dev/null +++ b/src/food-market.pos/Tooling/BenchRunner.cs @@ -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; + +/// Замеры старта/синхронизации кассы против живого сервера. +/// Запуск: dotnet run -- --bench <serverUrl> <user> <password>. +/// +/// Мерит: (1) первичную полную загрузку каталога (HTTP+десериализация отдельно +/// от записи в SQLite), (2) тёплый старт — открытие локальной базы + поиск, +/// (3) дельта-синк (изменений ~нет). Всё — с включённой gzip-декомпрессией. +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 "); return 2; } + return RunAsync(rest[0].TrimEnd('/'), rest[1], rest[2]).GetAwaiter().GetResult(); + } + + private static async Task 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(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 SendAsync(HttpRequestMessage req, CancellationToken ct) + { + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return base.SendAsync(req, ct); + } + } +}