feat(stage): demo-data seeder для test.admin.food-market.kz
Some checks failed
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 Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 10:17:49 +05:00
parent d89d6bf1dc
commit ad09b56013
6 changed files with 835 additions and 2 deletions

View file

@ -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;
/// <summary>Заполнение тестового стейджа реалистичными демо-данными по запросу
/// admin'a текущего tenant'а. См. <see cref="DemoTenantSeeder"/> — там логика
/// (5 групп / 50 товаров / 10 контрагентов / документы).
///
/// Использование: с UI кнопка «Заполнить демо-данными» в OrganizationSettingsPage
/// (только когда счётчик товаров = 0 в идемпотентной проверке).
///
/// Безопасность: только Admin'ы и SuperAdmin (override mode). Не использовать
/// на проде (UI кнопку прячем когда товары уже есть; но даже если нажмут —
/// идемпотентный маркер DEMO- защитит от дубля).</summary>
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/seed-demo")]
public class DemoSeedController : ControllerBase
{
private readonly DemoTenantSeeder _seeder;
private readonly ITenantContext _tenant;
private readonly ILogger<DemoSeedController> _log;
public DemoSeedController(DemoTenantSeeder seeder, ITenantContext tenant, ILogger<DemoSeedController> log)
{
_seeder = seeder;
_tenant = tenant;
_log = log;
}
/// <summary>Сводка: какие демо-сущности уже наполнены. Дешёвый — только count'ы,
/// не вызывает seed. UI использует чтобы выбрать «Заполнить» vs «Сводка».</summary>
[HttpGet("status")]
public async Task<ActionResult<DemoTenantSeeder.SeedSummary>> Status(CancellationToken ct)
{
var orgId = _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant in context");
return Ok(await _seeder.PeekAsync(orgId, ct));
}
/// <summary>Запустить seed демо-данных. Идемпотентен — если уже наполнено,
/// возвращает existing summary без вставок.</summary>
[HttpPost]
public async Task<ActionResult<DemoTenantSeeder.SeedSummary>> 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);
}
}

View file

@ -302,6 +302,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
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 —

View file

@ -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;
/// <summary>Заполняет КОНКРЕТНОГО 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 секунд.</summary>
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<SeedSummary> 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<ProductGroup>();
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<Product>();
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<ProductPrice>
{
new ProductPrice
{
OrganizationId = orgId,
PriceTypeId = retailPrice.Id,
Amount = t.RetailPrice,
CurrencyId = currency.Id,
},
},
Barcodes = new List<ProductBarcode>
{
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<Counterparty>();
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<Supply>();
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));
}
/// <summary>Только считает, ничего не вставляет. Используется в GET-status.</summary>
public async Task<SeedSummary> 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<SeedSummary> 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),
},
};
}

View file

@ -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 && <span className="text-sm text-emerald-600">Сохранено</span>}
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
</div>
<DemoSeedSection />
</div>
</div>
)
}
/**
* Кнопка «Заполнить демо-данными» для стейджа: 50 товаров / 10 контрагентов /
* наполненные отчёты одной кнопкой. Запрос идемпотентен; на уже заполненной org
* показываем сводку «Уже заполнено: ».
*/
function DemoSeedSection() {
const qc = useQueryClient()
const status = useQuery<DemoSeedSummary>({
queryKey: ['/api/admin/seed-demo/status'],
queryFn: async () => (await api.get<DemoSeedSummary>('/api/admin/seed-demo/status')).data,
})
const seed = useMutation({
mutationFn: async () => (await api.post<DemoSeedSummary>('/api/admin/seed-demo')).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/admin/seed-demo/status'] })
},
})
const s = status.data
const seeded = !!s?.alreadySeeded
return (
<section className="border-t border-slate-200 pt-6 mt-6">
<h2 className="text-base font-semibold flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Демо-данные
</h2>
<p className="text-sm text-slate-500 mt-1">
Заполняет организацию реалистичными данными для демонстрации стейджа: 50 товаров
в 5 группах, 10 контрагентов, 5 приёмок, 30 продаж, 1 опт-отгрузка,
1 списание, 1 перемещение, 1 инвентаризация. Идемпотентно повторный
запуск не создаст дубликатов.
</p>
{status.isLoading && (
<p className="text-sm text-slate-500 mt-3">Загружаю статус</p>
)}
{s && (
<div className="mt-3 text-xs text-slate-600 grid grid-cols-2 sm:grid-cols-5 gap-x-4 gap-y-1">
<div>Товаров: <b>{s.products}</b></div>
<div>Групп: <b>{s.groups}</b></div>
<div>Контрагентов: <b>{s.counterparties}</b></div>
<div>Складов: <b>{s.stores}</b></div>
<div>Приёмок: <b>{s.supplies}</b></div>
<div>Продаж: <b>{s.sales}</b></div>
<div>Опт: <b>{s.demands}</b></div>
<div>Списаний: <b>{s.losses}</b></div>
<div>Перемещений: <b>{s.transfers}</b></div>
<div>Инвентар.: <b>{s.inventories}</b></div>
</div>
)}
<div className="mt-4 flex gap-3 items-center">
<Button
onClick={() => seed.mutate()}
disabled={seed.isPending || seeded}
variant={seeded ? 'secondary' : 'primary'}
>
<Sparkles className="w-4 h-4" />
{seed.isPending ? 'Наполняю…' : seeded ? 'Уже заполнено' : 'Заполнить демо-данными'}
</Button>
{seed.isSuccess && !seeded && (
<span className="text-sm text-emerald-600">
Демо-данные созданы. Перезагрузите страницу каталога чтобы увидеть.
</span>
)}
{seed.error && (
<span className="text-sm text-red-600">{(seed.error as Error).message}</span>
)}
</div>
</section>
)
}

View file

@ -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<void> {
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}` })
}

View file

@ -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 (не дублировано)