Compare commits
No commits in common. "e78f14bc6b4fcc11ce3c975a2ef3063b3bc3ea4a" and "0dc8703712dc35af2163ed6e9cb2013b3952e6d0" have entirely different histories.
e78f14bc6b
...
0dc8703712
|
|
@ -34,19 +34,17 @@ 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<DemoSeedController> _log;
|
||||
|
||||
public DemoSeedController(
|
||||
DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder,
|
||||
ShowcaseSeeder showcaseSeeder, BulkCatalogSeeder bulkSeeder,
|
||||
ShowcaseSeeder showcaseSeeder,
|
||||
ITenantContext tenant, ILogger<DemoSeedController> log)
|
||||
{
|
||||
_shortSeeder = shortSeeder;
|
||||
_yearSeeder = yearSeeder;
|
||||
_showcaseSeeder = showcaseSeeder;
|
||||
_bulkSeeder = bulkSeeder;
|
||||
_tenant = tenant;
|
||||
_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
|
||||
/// (см. <see cref="YearDemoSeeder"/>): 200 товаров / 1500 продаж / год.</summary>
|
||||
[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
|
||||
?? 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);
|
||||
|
|
|
|||
|
|
@ -109,16 +109,7 @@ orderby l.SortOrder
|
|||
if (resolvedStore is null)
|
||||
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
|
||||
|
||||
// `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 threshold = since ?? DateTime.MinValue.ToUniversalTime();
|
||||
var serverTime = DateTime.UtcNow;
|
||||
|
||||
// Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt
|
||||
|
|
@ -177,23 +168,17 @@ orderby l.SortOrder
|
|||
UpdatedAt = stamp,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// Остатки. Первичная загрузка (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,
|
||||
Quantity = s.Quantity,
|
||||
AsOf = serverTime,
|
||||
}).ToListAsync(ct);
|
||||
// Остатки: всегда полный снимок на момент ответа. since-фильтр не
|
||||
// применяется к остаткам — кассе всегда нужен актуальный stock.
|
||||
var stocks = await (from s in _db.Stocks.AsNoTracking()
|
||||
where s.StoreId == resolvedStore.Value && s.Quantity != 0m
|
||||
select new StockSyncDto
|
||||
{
|
||||
ProductId = s.ProductId,
|
||||
StoreId = s.StoreId,
|
||||
Quantity = s.Quantity,
|
||||
AsOf = serverTime,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// Контрагенты-покупатели. Поставщиков на POS не шлём.
|
||||
var counterparties = await (from c in _db.Counterparties.AsNoTracking()
|
||||
|
|
@ -218,7 +203,6 @@ orderby l.SortOrder
|
|||
Products = productDtos,
|
||||
Prices = prices,
|
||||
Stocks = stocks,
|
||||
StocksFullSnapshot = stocksFull,
|
||||
Counterparties = counterparties,
|
||||
DeletedProductIds = Array.Empty<Guid>(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -527,7 +527,6 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
||||
// Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1).
|
||||
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
|
||||
builder.Services.AddScoped<foodmarket.Api.Seed.BulkCatalogSeeder>();
|
||||
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||||
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
||||
// Keep the file as reference for anyone starting without MoySklad access —
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,13 +100,10 @@ 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 (productIdSet.Contains(kv.Key)) continue;
|
||||
if (sync.Products.Any(x => x.Id == 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());
|
||||
|
|
@ -117,12 +114,11 @@ 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();
|
||||
}
|
||||
// Остатки. Полный снимок (первичная загрузка) → перезаписываем целиком.
|
||||
// Дельта → применяем построчно: ненулевые upsert, нулевые (вернулись к 0)
|
||||
// удаляем. Так рекуррентный тик не трогает все 30 000 строк каждый раз.
|
||||
if (sync.StocksFullSnapshot)
|
||||
// Остатки — ПОЛНЫЙ снимок ненулевых остатков склада (сервер шлёт всё каждый
|
||||
// раз, не дельту). Поэтому перезаписываем целиком: товар, вернувшийся к 0,
|
||||
// выпадает из снимка → строка удаляется → GetStock вернёт null (=0, не минус).
|
||||
using (var delStock = _cn.CreateCommand())
|
||||
{
|
||||
using var delStock = _cn.CreateCommand();
|
||||
delStock.Transaction = tx; delStock.CommandText = "DELETE FROM stock"; delStock.ExecuteNonQuery();
|
||||
}
|
||||
if (sync.Stocks.Count > 0)
|
||||
|
|
@ -130,24 +126,14 @@ 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(); delSt.Dispose();
|
||||
insSt.Dispose();
|
||||
}
|
||||
tx.Commit();
|
||||
SetKv("last_sync", sync.ServerTime.ToString("O"));
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -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(NewHandler()) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
Http = new HttpClient { 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, NewHandler()))
|
||||
var client = new HttpClient(new AuthHeaderHandler(() => Session?.AccessToken, new HttpClientHandler()))
|
||||
{
|
||||
BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')),
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
|
|
@ -66,16 +66,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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 <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using foodmarket.Pos.Core.Auth;
|
||||
|
|
@ -65,31 +64,6 @@ 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}")
|
||||
|
|
@ -102,7 +76,7 @@ private string LastSyncText
|
|||
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Проведите инвентаризацию по этим товарам."; 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(); }
|
||||
|
||||
|
|
@ -117,11 +91,6 @@ 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)
|
||||
{
|
||||
|
|
@ -230,42 +199,17 @@ 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 || _syncRunning) return;
|
||||
_syncRunning = true;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => IsSyncing = true);
|
||||
bool online;
|
||||
if (_svc?.Sync is null) return;
|
||||
try
|
||||
{
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
await _svc.Sync.PushSalesAsync();
|
||||
await _svc.Sync.PullCatalogAsync();
|
||||
});
|
||||
online = true;
|
||||
await _svc.Sync.PushSalesAsync();
|
||||
await _svc.Sync.PullCatalogAsync();
|
||||
IsOnline = true;
|
||||
}
|
||||
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;
|
||||
});
|
||||
catch { IsOnline = false; }
|
||||
finally { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,9 +68,6 @@
|
|||
<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>
|
||||
|
|
@ -78,19 +75,7 @@
|
|||
</Border>
|
||||
|
||||
<!-- Текущая страница -->
|
||||
<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>
|
||||
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}"/>
|
||||
|
||||
<!-- Полоса горячих клавиш -->
|
||||
<Border Grid.Row="2" Background="{DynamicResource PosCard}" BorderBrush="{DynamicResource PosBorder}" BorderThickness="0,1,0,0" Padding="24,0">
|
||||
|
|
|
|||
|
|
@ -94,13 +94,6 @@ 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 пробил чек локально, теперь
|
||||
|
|
|
|||
|
|
@ -185,15 +185,13 @@ public async Task ClientSaleId_idempotency_across_batches()
|
|||
}
|
||||
|
||||
[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());
|
||||
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, продаём 3 → уйдём в −3.
|
||||
// Не подкармливаем — остаток 0.
|
||||
|
||||
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);
|
||||
post.EnsureSuccessStatusCode();
|
||||
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
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, "создано предупреждение о минусе");
|
||||
resp!.Accepted.Should().BeEmpty();
|
||||
resp.Failed.Should().HaveCount(1);
|
||||
resp.Failed[0].Error.Should().Contain("Недостаточный остаток");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -159,15 +159,6 @@ 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>(),
|
||||
};
|
||||
|
||||
|
|
@ -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.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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue