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
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:
parent
d89d6bf1dc
commit
ad09b56013
57
src/food-market.api/Controllers/Admin/DemoSeedController.cs
Normal file
57
src/food-market.api/Controllers/Admin/DemoSeedController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -302,6 +302,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
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>();
|
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||||||
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
||||||
// Keep the file as reference for anyone starting without MoySklad access —
|
// Keep the file as reference for anyone starting without MoySklad access —
|
||||||
|
|
|
||||||
539
src/food-market.api/Seed/DemoTenantSeeder.cs
Normal file
539
src/food-market.api/Seed/DemoTenantSeeder.cs
Normal 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Save } from 'lucide-react'
|
import { Save, Sparkles } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
|
|
@ -8,6 +8,13 @@ import { Field, TextInput, Select, Checkbox } from '@/components/Field'
|
||||||
import { useCountries } from '@/lib/useLookups'
|
import { useCountries } from '@/lib/useLookups'
|
||||||
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
|
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() {
|
export function OrganizationSettingsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const settings = useOrgSettings()
|
const settings = useOrgSettings()
|
||||||
|
|
@ -195,7 +202,84 @@ export function OrganizationSettingsPage() {
|
||||||
{save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>}
|
{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>}
|
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DemoSeedSection />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
130
tests/e2e/scenarios/stage-demo-seed.steps.ts
Normal file
130
tests/e2e/scenarios/stage-demo-seed.steps.ts
Normal 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}` })
|
||||||
|
}
|
||||||
21
tests/e2e/scenarios/stage-demo-seed.yml
Normal file
21
tests/e2e/scenarios/stage-demo-seed.yml
Normal 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 (не дублировано)
|
||||||
Loading…
Reference in a new issue