From ad09b56013056aaca5cca6a9b46cc9afa12628ea Mon Sep 17 00:00:00 2001 From: nns Date: Sat, 30 May 2026 10:17:49 +0500 Subject: [PATCH] =?UTF-8?q?feat(stage):=20demo-data=20seeder=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20test.admin.food-market.kz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 1 Sprint 7 — кнопка «Заполнить демо-данными» в OrganizationSettingsPage. Что заполняет (за одну транзакцию, ~3с на стейдже): - 5 групп товаров (Молочные / Хлеб / Напитки / Бакалея / Снеки) - 50 товаров с барштрихкодами EAN-13 + retail-ценой (article DEMO-NN-MM) - 10 контрагентов (5 поставщиков + 5 покупателей-юрлиц с BIN) - Второй склад «Резерв» (если нет) для transfer'a - 5 приёмок (Posted) за последние 30 дней с moving-average cost - 30 розничных продаж (Posted) за последний месяц, Cash/Card случайно - 1 опт-отгрузка (Demand, Posted) с 15% скидкой - 1 списание (Loss, Posted, причина Expired) - 1 перемещение (Transfer, Posted) между складами - 1 инвентаризация (Posted) с небольшим diff +/- 1 Идемпотентность: маркер — наличие Product с Article startsWith "DEMO-". Повторный POST → возвращает summary без вставок. API: - GET /api/admin/seed-demo/status — счётчики (Admin policy) - POST /api/admin/seed-demo — запустить (Admin policy) UI: OrganizationSettingsPage.tsx, секция «Демо-данные» с Sparkles-иконкой, counts grid и кнопкой (disabled когда уже заполнено). Тесты: tests/e2e/scenarios/stage-demo-seed (5/5 ✓ локально). Co-Authored-By: Claude Opus 4.7 --- .../Controllers/Admin/DemoSeedController.cs | 57 ++ src/food-market.api/Program.cs | 2 + src/food-market.api/Seed/DemoTenantSeeder.cs | 539 ++++++++++++++++++ .../src/pages/OrganizationSettingsPage.tsx | 88 ++- tests/e2e/scenarios/stage-demo-seed.steps.ts | 130 +++++ tests/e2e/scenarios/stage-demo-seed.yml | 21 + 6 files changed, 835 insertions(+), 2 deletions(-) create mode 100644 src/food-market.api/Controllers/Admin/DemoSeedController.cs create mode 100644 src/food-market.api/Seed/DemoTenantSeeder.cs create mode 100644 tests/e2e/scenarios/stage-demo-seed.steps.ts create mode 100644 tests/e2e/scenarios/stage-demo-seed.yml diff --git a/src/food-market.api/Controllers/Admin/DemoSeedController.cs b/src/food-market.api/Controllers/Admin/DemoSeedController.cs new file mode 100644 index 0000000..de7f7be --- /dev/null +++ b/src/food-market.api/Controllers/Admin/DemoSeedController.cs @@ -0,0 +1,57 @@ +using foodmarket.Api.Seed; +using foodmarket.Application.Common.Tenancy; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace foodmarket.Api.Controllers.Admin; + +/// Заполнение тестового стейджа реалистичными демо-данными по запросу +/// admin'a текущего tenant'а. См. — там логика +/// (5 групп / 50 товаров / 10 контрагентов / документы). +/// +/// Использование: с UI кнопка «Заполнить демо-данными» в OrganizationSettingsPage +/// (только когда счётчик товаров = 0 в идемпотентной проверке). +/// +/// Безопасность: только Admin'ы и SuperAdmin (override mode). Не использовать +/// на проде (UI кнопку прячем когда товары уже есть; но даже если нажмут — +/// идемпотентный маркер DEMO- защитит от дубля). +[ApiController] +[Authorize(Policy = "AdminAccess")] +[Route("api/admin/seed-demo")] +public class DemoSeedController : ControllerBase +{ + private readonly DemoTenantSeeder _seeder; + private readonly ITenantContext _tenant; + private readonly ILogger _log; + + public DemoSeedController(DemoTenantSeeder seeder, ITenantContext tenant, ILogger log) + { + _seeder = seeder; + _tenant = tenant; + _log = log; + } + + /// Сводка: какие демо-сущности уже наполнены. Дешёвый — только count'ы, + /// не вызывает seed. UI использует чтобы выбрать «Заполнить» vs «Сводка». + [HttpGet("status")] + public async Task> Status(CancellationToken ct) + { + var orgId = _tenant.OrganizationId + ?? throw new InvalidOperationException("No tenant in context"); + return Ok(await _seeder.PeekAsync(orgId, ct)); + } + + /// Запустить seed демо-данных. Идемпотентен — если уже наполнено, + /// возвращает existing summary без вставок. + [HttpPost] + public async Task> Run(CancellationToken ct) + { + var orgId = _tenant.OrganizationId + ?? throw new InvalidOperationException("No tenant in context"); + _log.LogInformation("Demo seed requested for org={OrgId}", orgId); + var result = await _seeder.SeedAsync(orgId, ct); + _log.LogInformation("Demo seed done for org={OrgId}: products={Products} sales={Sales} already={Already}", + orgId, result.Products, result.Sales, result.AlreadySeeded); + return Ok(result); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index beadf32..89f1b5a 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -302,6 +302,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + // Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo). + 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/DemoTenantSeeder.cs b/src/food-market.api/Seed/DemoTenantSeeder.cs new file mode 100644 index 0000000..729ddbb --- /dev/null +++ b/src/food-market.api/Seed/DemoTenantSeeder.cs @@ -0,0 +1,539 @@ +using foodmarket.Application.Inventory; +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Purchases; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Seed; + +/// Заполняет КОНКРЕТНОГО tenant'а реалистичными демо-данными для +/// показа стенда: 5 групп / 50 товаров / 10 контрагентов / 5 приёмок (Posted) +/// / 30 розничных продаж (Posted) / 1 опт. отгрузка / 1 списание / 1 перемещение +/// / 1 инвентаризация. Идемпотентен: маркер — наличие любого Product с +/// Article startsWith "DEMO-" в этой org. Повторный вызов возвращает summary +/// без повторных вставок. +/// +/// Запускается через POST /api/admin/seed-demo (см. DemoSeedController). +/// Внутри одной транзакции; на стейдже занимает 2-5 секунд. +public sealed class DemoTenantSeeder +{ + private readonly AppDbContext _db; + private readonly IStockService _stock; + private readonly Random _rng = new(42); // детерминированно для воспроизводимости + + public DemoTenantSeeder(AppDbContext db, IStockService stock) + { + _db = db; + _stock = stock; + } + + public sealed record SeedSummary( + bool AlreadySeeded, + int Groups, int Products, int Counterparties, + int Supplies, int Sales, int Demands, int Losses, int Transfers, int Inventories, + int Stores); + + public async Task SeedAsync(Guid orgId, CancellationToken ct = default) + { + // Idempotency marker: artikul DEMO-* (включает и фолбэк DEMO-{group}-NN). + var alreadySeeded = await _db.Products.IgnoreQueryFilters() + .AnyAsync(p => p.OrganizationId == orgId && p.Article != null && p.Article.StartsWith("DEMO-"), ct); + if (alreadySeeded) + { + // Возвращаем какие данные уже есть, чтобы UI мог показать summary. + return await CountExistingAsync(orgId, ct); + } + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + // ── Подтянем системные ссылки ─────────────────────────────────────── + var unitKg = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "166", ct); + var unitSht = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "796", ct); + var unitL = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "112", ct); + var retailPrice = await _db.PriceTypes.IgnoreQueryFilters().FirstAsync(p => p.OrganizationId == orgId && p.IsRetail, ct); + var currency = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct); + var orgRoot = await _db.ProductGroups.IgnoreQueryFilters() + .FirstOrDefaultAsync(g => g.OrganizationId == orgId && g.ParentId == null, ct); + + // ── Магазины: основной уже есть из bootstrap'а; делаем второй для transfer'а ── + var stores = await _db.Stores.IgnoreQueryFilters() + .Where(s => s.OrganizationId == orgId).ToListAsync(ct); + var mainStore = stores.First(s => s.IsMain); + var secondStore = stores.FirstOrDefault(s => !s.IsMain); + if (secondStore is null) + { + secondStore = new Store + { + OrganizationId = orgId, Name = "Склад резерв", Code = "RESERVE", + Address = "Алматы, Тестовая 2", IsMain = false, IsActive = true, + }; + _db.Stores.Add(secondStore); + } + + // ── Группы товаров (5 категорий) ──────────────────────────────────── + var grpNames = new[] { "Молочные продукты", "Хлеб и выпечка", "Напитки", "Бакалея", "Снеки" }; + var groups = new List(); + foreach (var name in grpNames) + { + var g = new ProductGroup + { + OrganizationId = orgId, Name = name, ParentId = null, + Path = name, SortOrder = 10, MarkupPercent = 30m, + }; + _db.ProductGroups.Add(g); + groups.Add(g); + } + + // ── 50 товаров (по 10 на группу) ──────────────────────────────────── + var productTemplates = BuildProductCatalog(); + var products = new List(); + var barcodeSeq = 200_000_000_001L; + for (int g = 0; g < groups.Count; g++) + { + for (int i = 0; i < 10; i++) + { + var t = productTemplates[g][i]; + var unit = t.Unit == "kg" ? unitKg : t.Unit == "l" ? unitL : unitSht; + var packaging = t.Unit == "kg" ? Packaging.Weight : t.Unit == "l" ? Packaging.Liquid : Packaging.Piece; + var article = $"DEMO-{g + 1:D2}-{i + 1:D2}"; + var product = new Product + { + OrganizationId = orgId, + Name = t.Name, + Article = article, + Description = null, + UnitOfMeasureId = unit.Id, + Vat = 16m, VatEnabled = true, + ProductGroupId = groups[g].Id, + Packaging = packaging, + ReferencePrice = t.RetailPrice, + Prices = new List + { + new ProductPrice + { + OrganizationId = orgId, + PriceTypeId = retailPrice.Id, + Amount = t.RetailPrice, + CurrencyId = currency.Id, + }, + }, + Barcodes = new List + { + new ProductBarcode + { + OrganizationId = orgId, + Code = MakeEan13(barcodeSeq++), + Type = BarcodeType.Ean13, IsPrimary = true, + }, + }, + }; + _db.Products.Add(product); + products.Add(product); + } + } + + // ── 10 контрагентов (5 поставщиков + 5 покупателей-юрлиц) ─────────── + var counterparties = new List(); + var supplierNames = new[] { "ТОО «Алматы Фуд»", "ТОО «Карагандинский молокозавод»", "ИП Бегимбаев К.Т.", "ТОО «Хлебокомбинат Астана»", "ТОО «Шымкент Дистрибьюшн»" }; + var customerNames = new[] { "ТОО «Сеть кафе Бариста»", "ТОО «Школа-лицей №5»", "ТОО «Гостиница Алтын»", "ТОО «Корпоративное питание KZ»", "ТОО «Сеть пекарен Самал»" }; + foreach (var name in supplierNames) + { + counterparties.Add(new Counterparty + { + OrganizationId = orgId, Name = name, LegalName = name, + Type = CounterpartyType.LegalEntity, + Bin = NextBin(barcodeSeq++), + Phone = $"+7 (727) 1{_rng.Next(10, 99)}-{_rng.Next(10, 99)}-{_rng.Next(10, 99)}", + Notes = "DEMO supplier", + }); + } + foreach (var name in customerNames) + { + counterparties.Add(new Counterparty + { + OrganizationId = orgId, Name = name, LegalName = name, + Type = CounterpartyType.LegalEntity, + Bin = NextBin(barcodeSeq++), + Phone = $"+7 (727) 2{_rng.Next(10, 99)}-{_rng.Next(10, 99)}-{_rng.Next(10, 99)}", + Notes = "DEMO customer", + }); + } + _db.Counterparties.AddRange(counterparties); + + // Сохраняем сначала справочники: дальше нужны их ID для документов. + await _db.SaveChangesAsync(ct); + + var suppliers = counterparties.Take(5).ToList(); + var customers = counterparties.Skip(5).ToList(); + + // ── 5 приёмок за последние 30 дней (Posted), стоковость + Cost ────── + var now = DateTime.UtcNow; + var supplies = new List(); + for (int i = 0; i < 5; i++) + { + var date = now.AddDays(-_rng.Next(1, 30)); + var supply = new Supply + { + OrganizationId = orgId, + Number = $"П-DEMO-{i + 1:D3}", + Date = date, Status = SupplyStatus.Posted, PostedAt = date, + SupplierId = suppliers[i % suppliers.Count].Id, + StoreId = mainStore.Id, CurrencyId = currency.Id, + Notes = "DEMO", + }; + var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(5, 10)).ToList(); + int order = 0; + decimal total = 0m; + foreach (var p in picked) + { + var qty = _rng.Next(20, 100); + var price = Math.Round(p.ReferencePrice!.Value * 0.6m, 2); // закупка ~60% от розницы + var lt = qty * price; + supply.Lines.Add(new SupplyLine + { + OrganizationId = orgId, + ProductId = p.Id, Quantity = qty, UnitPrice = price, + LineTotal = lt, SortOrder = order++, + }); + total += lt; + // Cost: moving average; для первого захода ставим = цена приёмки (currentQty=0). + var currentQty = await _db.Stocks + .Where(s => s.ProductId == p.Id) + .SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m; + p.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(currentQty, p.Cost, qty, price); + p.LastSupplyAt = date; + // Stock + movement + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: p.Id, StoreId: mainStore.Id, Quantity: qty, + Type: MovementType.Supply, DocumentType: "supply", + DocumentId: supply.Id, DocumentNumber: supply.Number, + UnitCost: price, OccurredAt: date), ct); + } + supply.Total = total; + _db.Supplies.Add(supply); + supplies.Add(supply); + // SaveChanges после каждой приёмки — иначе StockService при следующей + // итерации не увидит свежесозданные Stock-строки (FirstOrDefault через + // DbSet идёт в БД минуя ChangeTracker.Local), и упирается в 23505 по + // unique-индексу (OrganizationId, ProductId, StoreId). + await _db.SaveChangesAsync(ct); + } + + // ── 30 розничных продаж (Posted), последний месяц ─────────────────── + var retailPoint = await _db.RetailPoints.IgnoreQueryFilters() + .FirstOrDefaultAsync(rp => rp.OrganizationId == orgId, ct); + for (int i = 0; i < 30; i++) + { + var date = now.AddDays(-_rng.Next(0, 28)).AddHours(-_rng.Next(0, 12)); + var sale = new RetailSale + { + OrganizationId = orgId, + Number = $"ПР-DEMO-{i + 1:D3}", + Date = date, Status = RetailSaleStatus.Posted, PostedAt = date, + StoreId = mainStore.Id, RetailPointId = retailPoint?.Id, + CurrencyId = currency.Id, + Payment = (PaymentMethod)(_rng.Next(0, 2)), // Cash / Card + IsReturn = false, + }; + var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(1, 5)).ToList(); + int order = 0; + decimal subtotal = 0; + foreach (var p in picked) + { + var qty = _rng.Next(1, 4); + var price = p.ReferencePrice!.Value; + var lt = qty * price; + sale.Lines.Add(new RetailSaleLine + { + OrganizationId = orgId, + ProductId = p.Id, Quantity = qty, UnitPrice = price, + LineTotal = lt, Discount = 0m, VatPercent = 16m, + SortOrder = order++, + }); + subtotal += lt; + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: p.Id, StoreId: mainStore.Id, Quantity: -qty, + Type: MovementType.RetailSale, DocumentType: "retail-sale", + DocumentId: sale.Id, DocumentNumber: sale.Number, + UnitCost: price, OccurredAt: date), ct); + } + sale.Subtotal = subtotal; + sale.DiscountTotal = 0; + sale.Total = subtotal; + sale.PaidCash = sale.Payment == PaymentMethod.Cash ? subtotal : 0m; + sale.PaidCard = sale.Payment == PaymentMethod.Card ? subtotal : 0m; + _db.RetailSales.Add(sale); + // SaveChanges после каждой продажи: те же причины что в supplies-цикле. + await _db.SaveChangesAsync(ct); + } + + // ── 1 опт. отгрузка (Demand) ──────────────────────────────────────── + var demandPicked = products.OrderBy(_ => _rng.Next()).Take(3).ToList(); + var demand = new Demand + { + OrganizationId = orgId, + Number = "ОПТ-DEMO-001", + Date = now.AddDays(-7), Status = DemandStatus.Posted, PostedAt = now.AddDays(-7), + CustomerId = customers[0].Id, StoreId = mainStore.Id, CurrencyId = currency.Id, + Payment = DemandPayment.BankTransfer, PaidAmount = 0, Notes = "DEMO", + }; + decimal dTot = 0; + int dOrder = 0; + foreach (var p in demandPicked) + { + var qty = _rng.Next(5, 15); + var price = Math.Round(p.ReferencePrice!.Value * 0.85m, 2); // опт-скидка 15% + var lt = qty * price; + demand.Lines.Add(new DemandLine + { + OrganizationId = orgId, + ProductId = p.Id, Quantity = qty, UnitPrice = price, + LineTotal = lt, Discount = 0m, VatPercent = 16m, SortOrder = dOrder++, + }); + dTot += lt; + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: p.Id, StoreId: mainStore.Id, Quantity: -qty, + Type: MovementType.WholesaleSale, DocumentType: "demand", + DocumentId: demand.Id, DocumentNumber: demand.Number, + UnitCost: price, OccurredAt: demand.Date), ct); + } + demand.Subtotal = dTot; + demand.Total = dTot; + _db.Demands.Add(demand); + + // ── 1 списание (Loss) ────────────────────────────────────────────── + var lossProd = products[_rng.Next(products.Count)]; + var loss = new Loss + { + OrganizationId = orgId, + Number = "С-DEMO-001", + Date = now.AddDays(-3), Status = LossStatus.Posted, PostedAt = now.AddDays(-3), + StoreId = mainStore.Id, CurrencyId = currency.Id, + Reason = LossReason.Expired, Notes = "DEMO — просрочка", + Total = lossProd.Cost * 2, + }; + loss.Lines.Add(new LossLine + { + OrganizationId = orgId, + ProductId = lossProd.Id, Quantity = 2, UnitCost = lossProd.Cost, + LineTotal = lossProd.Cost * 2, SortOrder = 0, + }); + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: lossProd.Id, StoreId: mainStore.Id, Quantity: -2, + Type: MovementType.WriteOff, DocumentType: "loss", + DocumentId: loss.Id, DocumentNumber: loss.Number, + UnitCost: lossProd.Cost, OccurredAt: loss.Date), ct); + _db.Losses.Add(loss); + + // ── 1 перемещение (Transfer): между main и second ────────────────── + var transferProd = products[_rng.Next(products.Count)]; + var transferQty = 10; + var transfer = new Transfer + { + OrganizationId = orgId, + Number = "ПМ-DEMO-001", + Date = now.AddDays(-5), Status = TransferStatus.Posted, PostedAt = now.AddDays(-5), + FromStoreId = mainStore.Id, ToStoreId = secondStore.Id, + Notes = "DEMO", + }; + transfer.Lines.Add(new TransferLine + { + OrganizationId = orgId, + TransferId = transfer.Id, + ProductId = transferProd.Id, Quantity = transferQty, UnitCost = transferProd.Cost, + LineTotal = transferProd.Cost * transferQty, SortOrder = 0, + }); + transfer.Total = transferProd.Cost * transferQty; + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: transferProd.Id, StoreId: mainStore.Id, Quantity: -transferQty, + Type: MovementType.TransferOut, DocumentType: "transfer-out", + DocumentId: transfer.Id, DocumentNumber: transfer.Number, + UnitCost: transferProd.Cost, OccurredAt: transfer.Date), ct); + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: transferProd.Id, StoreId: secondStore.Id, Quantity: transferQty, + Type: MovementType.TransferIn, DocumentType: "transfer-in", + DocumentId: transfer.Id, DocumentNumber: transfer.Number, + UnitCost: transferProd.Cost, OccurredAt: transfer.Date), ct); + _db.Transfers.Add(transfer); + + // ── 1 инвентаризация (Posted) с небольшим diff ────────────────────── + var invProds = products.OrderBy(_ => _rng.Next()).Take(5).ToList(); + var inv = new InventoryDoc + { + OrganizationId = orgId, + Number = "ИНВ-DEMO-001", + Date = now.AddDays(-1), Status = InventoryStatus.Posted, PostedAt = now.AddDays(-1), + StoreId = mainStore.Id, Notes = "DEMO", + }; + int iOrder = 0; + foreach (var p in invProds) + { + var book = await _db.Stocks + .Where(s => s.StoreId == mainStore.Id && s.ProductId == p.Id) + .Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m; + // Для демо: +/- 1 от книги (можно случайно) + var diff = _rng.Next(-1, 2); + var actual = Math.Max(0m, book + diff); + inv.Lines.Add(new InventoryLine + { + OrganizationId = orgId, + InventoryDocId = inv.Id, + ProductId = p.Id, BookQty = book, ActualQty = actual, Diff = actual - book, + UnitCost = p.Cost, SortOrder = iOrder++, + }); + if (actual - book != 0m) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: p.Id, StoreId: mainStore.Id, Quantity: actual - book, + Type: MovementType.InventoryAdjustment, DocumentType: "inventory", + DocumentId: inv.Id, DocumentNumber: inv.Number, + UnitCost: p.Cost, OccurredAt: inv.Date, + Notes: actual > book ? "surplus" : "shortage"), ct); + } + } + _db.InventoryDocs.Add(inv); + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + return new SeedSummary( + AlreadySeeded: false, + Groups: groups.Count, + Products: products.Count, + Counterparties: counterparties.Count, + Supplies: supplies.Count, + Sales: 30, + Demands: 1, + Losses: 1, + Transfers: 1, + Inventories: 1, + Stores: stores.Count + (secondStore is { } ? 1 : 0)); + } + + /// Только считает, ничего не вставляет. Используется в GET-status. + public async Task PeekAsync(Guid orgId, CancellationToken ct) + { + var hasDemo = await _db.Products.IgnoreQueryFilters() + .AnyAsync(p => p.OrganizationId == orgId && p.Article != null && p.Article.StartsWith("DEMO-"), ct); + var counts = await CountExistingAsync(orgId, ct); + return counts with { AlreadySeeded = hasDemo }; + } + + private async Task CountExistingAsync(Guid orgId, CancellationToken ct) + { + var prodCount = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == orgId, ct); + var groupCount = await _db.ProductGroups.IgnoreQueryFilters().CountAsync(g => g.OrganizationId == orgId, ct); + var cpCount = await _db.Counterparties.IgnoreQueryFilters().CountAsync(c => c.OrganizationId == orgId, ct); + var supCount = await _db.Supplies.IgnoreQueryFilters().CountAsync(s => s.OrganizationId == orgId, ct); + var saleCount = await _db.RetailSales.IgnoreQueryFilters().CountAsync(s => s.OrganizationId == orgId, ct); + var dmCount = await _db.Demands.IgnoreQueryFilters().CountAsync(d => d.OrganizationId == orgId, ct); + var lossCount = await _db.Losses.IgnoreQueryFilters().CountAsync(l => l.OrganizationId == orgId, ct); + var trCount = await _db.Transfers.IgnoreQueryFilters().CountAsync(t => t.OrganizationId == orgId, ct); + var invCount = await _db.InventoryDocs.IgnoreQueryFilters().CountAsync(i => i.OrganizationId == orgId, ct); + var storeCount = await _db.Stores.IgnoreQueryFilters().CountAsync(s => s.OrganizationId == orgId, ct); + return new SeedSummary(true, + groupCount, prodCount, cpCount, + supCount, saleCount, dmCount, lossCount, trCount, invCount, storeCount); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private static string MakeEan13(long body12) + { + // body12 — 12 цифр; считаем чек-сумму EAN-13. + var s = body12.ToString("D12"); + var sum = 0; + for (int i = 0; i < 12; i++) + { + var d = s[i] - '0'; + sum += i % 2 == 0 ? d : d * 3; + } + var checksum = (10 - sum % 10) % 10; + return s + checksum; + } + + private static string NextBin(long seed) + { + // 12-значный BIN, начинается с 1234... для DEMO. + var s = $"1234{(seed % 100000000):D8}"; + return s[..12]; + } + + private sealed record ProductTpl(string Name, string Unit, decimal RetailPrice); + + private static ProductTpl[][] BuildProductCatalog() => new[] + { + // 0: Молочные + new[] + { + new ProductTpl("Молоко 3.2% 1л", "l", 480m), + new ProductTpl("Молоко 1.5% 1л", "l", 460m), + new ProductTpl("Кефир 1% 0.5л", "l", 280m), + new ProductTpl("Йогурт натуральный 250г", "sht", 250m), + new ProductTpl("Сметана 20% 200г", "sht", 320m), + new ProductTpl("Сыр Голландский 1кг", "kg", 3200m), + new ProductTpl("Творог 5% 200г", "sht", 360m), + new ProductTpl("Масло сливочное 200г", "sht", 850m), + new ProductTpl("Ряженка 1л", "l", 510m), + new ProductTpl("Сливки 20% 200мл", "sht", 420m), + }, + // 1: Хлеб + new[] + { + new ProductTpl("Хлеб ржаной 600г", "sht", 220m), + new ProductTpl("Хлеб пшеничный 500г", "sht", 200m), + new ProductTpl("Багет французский 250г", "sht", 280m), + new ProductTpl("Лаваш тонкий 300г", "sht", 180m), + new ProductTpl("Хлеб бородинский 400г", "sht", 240m), + new ProductTpl("Булочка с маком 80г", "sht", 95m), + new ProductTpl("Слойка с творогом 100г", "sht", 180m), + new ProductTpl("Пита 200г", "sht", 150m), + new ProductTpl("Сухари ванильные 200г", "sht", 220m), + new ProductTpl("Печенье овсяное 250г", "sht", 380m), + }, + // 2: Напитки + new[] + { + new ProductTpl("Вода негазированная 1.5л", "l", 280m), + new ProductTpl("Вода газированная 1.5л", "l", 290m), + new ProductTpl("Кола 0.5л", "l", 350m), + new ProductTpl("Сок яблочный 1л", "l", 520m), + new ProductTpl("Сок апельсиновый 1л", "l", 580m), + new ProductTpl("Чай чёрный пакетированный 25шт", "sht", 480m), + new ProductTpl("Чай зелёный 100г", "sht", 720m), + new ProductTpl("Кофе растворимый 100г", "sht", 1850m), + new ProductTpl("Энергетик 0.5л", "l", 580m), + new ProductTpl("Морс клюквенный 1л", "l", 420m), + }, + // 3: Бакалея + new[] + { + new ProductTpl("Рис круглозерный 1кг", "kg", 480m), + new ProductTpl("Гречка ядрица 800г", "sht", 520m), + new ProductTpl("Макароны спагетти 500г", "sht", 380m), + new ProductTpl("Сахар-песок 1кг", "kg", 420m), + new ProductTpl("Соль поваренная 1кг", "kg", 180m), + new ProductTpl("Мука пшеничная 2кг", "sht", 680m), + new ProductTpl("Масло подсолнечное 1л", "l", 1280m), + new ProductTpl("Уксус 9% 500мл", "sht", 250m), + new ProductTpl("Перец чёрный молотый 50г", "sht", 320m), + new ProductTpl("Лавровый лист 10г", "sht", 180m), + }, + // 4: Снеки + new[] + { + new ProductTpl("Чипсы Lay's 150г", "sht", 580m), + new ProductTpl("Сухарики Воронцовские 60г", "sht", 180m), + new ProductTpl("Орешки солёные 100г", "sht", 480m), + new ProductTpl("Семечки жареные 100г", "sht", 280m), + new ProductTpl("Шоколад молочный 90г", "sht", 580m), + new ProductTpl("Батончик Snickers 50г", "sht", 380m), + new ProductTpl("Конфеты ассорти 200г", "sht", 1280m), + new ProductTpl("Печенье шоколадное 200г", "sht", 480m), + new ProductTpl("Жевательная резинка 14г", "sht", 180m), + new ProductTpl("Мармелад 200г", "sht", 380m), + }, + }; +} diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index 70b8151..ef67d91 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Save } from 'lucide-react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Save, Sparkles } from 'lucide-react' import { api } from '@/lib/api' import { PageHeader } from '@/components/PageHeader' import { Button } from '@/components/Button' @@ -8,6 +8,13 @@ import { Field, TextInput, Select, Checkbox } from '@/components/Field' import { useCountries } from '@/lib/useLookups' import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings' +interface DemoSeedSummary { + alreadySeeded: boolean + groups: number; products: number; counterparties: number + supplies: number; sales: number; demands: number + losses: number; transfers: number; inventories: number; stores: number +} + export function OrganizationSettingsPage() { const qc = useQueryClient() const settings = useOrgSettings() @@ -195,7 +202,84 @@ export function OrganizationSettingsPage() { {save.isSuccess && Сохранено} {save.error && {(save.error as Error).message}} + + ) } + +/** + * Кнопка «Заполнить демо-данными» — для стейджа: 50 товаров / 10 контрагентов / + * наполненные отчёты одной кнопкой. Запрос идемпотентен; на уже заполненной org + * показываем сводку «Уже заполнено: …». + */ +function DemoSeedSection() { + const qc = useQueryClient() + const status = useQuery({ + queryKey: ['/api/admin/seed-demo/status'], + queryFn: async () => (await api.get('/api/admin/seed-demo/status')).data, + }) + const seed = useMutation({ + mutationFn: async () => (await api.post('/api/admin/seed-demo')).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/admin/seed-demo/status'] }) + }, + }) + + const s = status.data + const seeded = !!s?.alreadySeeded + + return ( +
+

+ + Демо-данные +

+

+ Заполняет организацию реалистичными данными для демонстрации стейджа: 50 товаров + в 5 группах, 10 контрагентов, 5 приёмок, 30 продаж, 1 опт-отгрузка, + 1 списание, 1 перемещение, 1 инвентаризация. Идемпотентно — повторный + запуск не создаст дубликатов. +

+ + {status.isLoading && ( +

Загружаю статус…

+ )} + + {s && ( +
+
Товаров: {s.products}
+
Групп: {s.groups}
+
Контрагентов: {s.counterparties}
+
Складов: {s.stores}
+
Приёмок: {s.supplies}
+
Продаж: {s.sales}
+
Опт: {s.demands}
+
Списаний: {s.losses}
+
Перемещений: {s.transfers}
+
Инвентар.: {s.inventories}
+
+ )} + +
+ + {seed.isSuccess && !seeded && ( + + Демо-данные созданы. Перезагрузите страницу каталога чтобы увидеть. + + )} + {seed.error && ( + {(seed.error as Error).message} + )} +
+
+ ) +} diff --git a/tests/e2e/scenarios/stage-demo-seed.steps.ts b/tests/e2e/scenarios/stage-demo-seed.steps.ts new file mode 100644 index 0000000..ef8dbc3 --- /dev/null +++ b/tests/e2e/scenarios/stage-demo-seed.steps.ts @@ -0,0 +1,130 @@ +/** + * Stage demo seeder: запуск + проверка наполнения + идемпотентность. + */ +import { login, makeClient } from '../lib/api.js' +import type { CheckResult, Step, Report } from '../lib/report.js' + +type Ctx = { + apiOnly: boolean + ts: number + email?: string + password?: string + token?: string + initialSummary?: SeedSummary +} +interface SeedSummary { + alreadySeeded: boolean + groups: number; products: number; counterparties: number + supplies: number; sales: number; demands: number + losses: number; transfers: number; inventories: number; stores: number +} +interface StepCtx { ctx: Ctx; step: Step; report: Report } + +const TS = Date.now() +function check(step: Step, c: CheckResult) { step.checks.push(c) } +function asString(x: unknown): string { + if (x == null) return '' + return typeof x === 'string' ? x : JSON.stringify(x) +} + +async function bootstrap(ctx: Ctx): Promise { + if (ctx.token) return + const api = makeClient() + ctx.email = `stage-seed-${TS}@food-market.local` + ctx.password = 'StageSeed12345!' + let r = await api.post('/api/auth/signup', { + email: ctx.email, password: ctx.password, + organizationName: `Seed ${TS}`, phone: '+77011190001', plan: 'start', + }) + for (let i = 0; i < 5 && r.status === 429; i++) { + await new Promise(res => setTimeout(res, 15000)) + r = await api.post('/api/auth/signup', { + email: ctx.email, password: ctx.password, + organizationName: `Seed ${TS}`, phone: '+77011190001', plan: 'start', + }) + } + if (r.status !== 200) throw new Error(`signup: ${r.status} ${JSON.stringify(r.data)}`) + const sess = await login(ctx.email, ctx.password) + ctx.token = sess.accessToken +} + +// --------------------------------------------------------------------------- + +export async function seed01_status_empty({ ctx, step, report }: StepCtx) { + ctx.ts = TS + await bootstrap(ctx) + const api = makeClient(ctx.token) + const r = await api.get('/api/admin/seed-demo/status') + check(step, { kind: 'api', description: 'GET /status → 200', ok: r.status === 200, detail: `${r.status}` }) + ctx.initialSummary = r.data as SeedSummary + check(step, { kind: 'api', description: 'alreadySeeded = false', ok: r.data?.alreadySeeded === false, detail: `already=${r.data?.alreadySeeded}` }) + check(step, { kind: 'api', description: 'products = 0', ok: r.data?.products === 0, detail: `products=${r.data?.products}` }) +} + +// --------------------------------------------------------------------------- + +export async function seed02_run({ ctx, step, report }: StepCtx) { + if (!ctx.token) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const r = await api.post('/api/admin/seed-demo', {}) + check(step, { kind: 'api', description: 'POST /seed-demo → 200', ok: r.status === 200, detail: `${r.status} ${asString(r.data).slice(0, 250)}` }) + if (r.status !== 200) return + const s = r.data as SeedSummary + check(step, { kind: 'api', description: 'alreadySeeded в ответе = false (только что заполнили)', ok: s.alreadySeeded === false, detail: `already=${s.alreadySeeded}` }) + check(step, { kind: 'api', description: 'products = 50', ok: s.products === 50, detail: `products=${s.products}` }) + check(step, { kind: 'api', description: 'groups = 5', ok: s.groups === 5, detail: `groups=${s.groups}` }) + check(step, { kind: 'api', description: 'counterparties = 10', ok: s.counterparties === 10, detail: `cp=${s.counterparties}` }) + check(step, { kind: 'api', description: 'supplies = 5, sales = 30', ok: s.supplies === 5 && s.sales === 30, detail: `sup=${s.supplies} sales=${s.sales}` }) + check(step, { kind: 'api', description: 'demands=1, losses=1, transfers=1, inventories=1', ok: s.demands === 1 && s.losses === 1 && s.transfers === 1 && s.inventories === 1, detail: `dm=${s.demands} loss=${s.losses} tr=${s.transfers} inv=${s.inventories}` }) + check(step, { kind: 'api', description: 'stores >= 2 (main + резерв для transfer)', ok: s.stores >= 2, detail: `stores=${s.stores}` }) +} + +// --------------------------------------------------------------------------- + +export async function seed03_status_after({ ctx, step, report }: StepCtx) { + if (!ctx.token) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const r = await api.get('/api/admin/seed-demo/status') + check(step, { kind: 'api', description: 'GET /status → alreadySeeded=true', ok: r.data?.alreadySeeded === true, detail: `already=${r.data?.alreadySeeded}` }) + check(step, { kind: 'api', description: 'products = 50 в статусе', ok: r.data?.products === 50, detail: `products=${r.data?.products}` }) +} + +// --------------------------------------------------------------------------- + +export async function seed04_data_visible_via_normal_apis({ ctx, step, report }: StepCtx) { + if (!ctx.token) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const [prods, supplies, sales, demands, transfers, invs, cps] = await Promise.all([ + api.get('/api/catalog/products?pageSize=200'), + api.get('/api/purchases/supplies?pageSize=50'), + api.get('/api/sales/retail?pageSize=50'), + api.get('/api/sales/demands?pageSize=50'), + api.get('/api/inventory/transfers?pageSize=50'), + api.get('/api/inventory/inventories?pageSize=50'), + api.get('/api/catalog/counterparties?pageSize=50'), + ]) + check(step, { kind: 'api', description: 'products total >= 50', ok: (prods.data?.total ?? 0) >= 50, detail: `total=${prods.data?.total}` }) + check(step, { kind: 'api', description: 'supplies total >= 5', ok: (supplies.data?.total ?? 0) >= 5, detail: `total=${supplies.data?.total}` }) + check(step, { kind: 'api', description: 'retail sales total >= 30', ok: (sales.data?.total ?? 0) >= 30, detail: `total=${sales.data?.total}` }) + check(step, { kind: 'api', description: 'demands total >= 1', ok: (demands.data?.total ?? 0) >= 1, detail: `total=${demands.data?.total}` }) + check(step, { kind: 'api', description: 'transfers total >= 1', ok: (transfers.data?.total ?? 0) >= 1, detail: `total=${transfers.data?.total}` }) + check(step, { kind: 'api', description: 'inventories total >= 1', ok: (invs.data?.total ?? 0) >= 1, detail: `total=${invs.data?.total}` }) + check(step, { kind: 'api', description: 'counterparties total >= 10', ok: (cps.data?.total ?? 0) >= 10, detail: `total=${cps.data?.total}` }) + + // Проверка: все demo-товары начинаются с DEMO- в article + const demoCount = (prods.data?.items ?? []).filter((p: { article?: string }) => p.article?.startsWith('DEMO-')).length + check(step, { kind: 'api', description: 'Все 50 demo-товаров имеют article DEMO-...', ok: demoCount === 50, detail: `demoCount=${demoCount}` }) +} + +// --------------------------------------------------------------------------- + +export async function seed05_idempotent({ ctx, step, report }: StepCtx) { + if (!ctx.token) { step.status = 'skip'; return } + const api = makeClient(ctx.token) + const second = await api.post('/api/admin/seed-demo', {}) + check(step, { kind: 'api', description: 'Повторный POST → 200, alreadySeeded=true', ok: second.status === 200 && second.data?.alreadySeeded === true, detail: `${second.status} already=${second.data?.alreadySeeded}` }) + check(step, { kind: 'api', description: 'products = 50 (не дублировано)', ok: second.data?.products === 50, detail: `products=${second.data?.products}` }) + + const prods = await api.get('/api/catalog/products?pageSize=200') + check(step, { kind: 'api', description: 'products total всё ещё 50', ok: (prods.data?.total ?? 0) === 50, detail: `total=${prods.data?.total}` }) +} diff --git a/tests/e2e/scenarios/stage-demo-seed.yml b/tests/e2e/scenarios/stage-demo-seed.yml new file mode 100644 index 0000000..2fbc8b1 --- /dev/null +++ b/tests/e2e/scenarios/stage-demo-seed.yml @@ -0,0 +1,21 @@ +name: stage-demo-seed +description: | + Demo seeder на test.admin.food-market.kz: создаёт org, проверяет + /status (alreadySeeded=false), запускает /run, проверяет что появились + товары/группы/контрагенты/документы; повторный /run идемпотентен. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: seed01_status_empty + title: Новый org → GET /api/admin/seed-demo/status → alreadySeeded=false, products=0 + - id: seed02_run + title: POST /api/admin/seed-demo → 200, alreadySeeded=false, products>=50 + - id: seed03_status_after + title: GET /status → alreadySeeded=true; products=50, supplies=5, sales=30, demands=1, losses=1, transfers=1, inventories=1, stores=2 + - id: seed04_data_visible_via_normal_apis + title: GET /api/catalog/products, /api/purchases/supplies, /api/sales/retail, /api/inventory/transfers — содержат демо-записи + - id: seed05_idempotent + title: Повторный POST /api/admin/seed-demo → products всё ещё 50 (не дублировано)