feat(seed): витринный сидер «Магазин у дома» для скриншотов сайта
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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 API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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 API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
ShowcaseSeeder — чистые данные для публичных скриншотов: 200 товаров без Article-маркеров, реальные EAN-13, боевой формат номеров документов (П-2026-…, ПР-2026-…), 3 поставщика, счета (касса/Kaspi/эквайринг), сотрудники с нормальными именами, ~30 дней продаж, приёмки, 2 списания, 1 завершённая инвентаризация. Без e2e/test/Y1/DEMO в видимых данных. - POST /api/admin/seed-demo?showcase=1 (DemoSeedController) - повторяемый deploy/showcase-seed.sh (signup + seed, идемпотентно) - YearDemoSeeder: data-массивы → internal для переиспользования Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
db8cd637e5
commit
9ddbffc04a
74
deploy/showcase-seed.sh
Executable file
74
deploy/showcase-seed.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env bash
|
||||
# Витринная организация «Магазин у дома» на стенде + наполнение правдоподобными
|
||||
# данными для скриншотов публичного сайта (ShowcaseSeeder). Повторяемый и
|
||||
# идемпотентный: при повторном запуске переиспользует организацию и не задваивает
|
||||
# данные (сидер проверяет наличие товаров).
|
||||
#
|
||||
# Требует задеплоенного на стенд API с эндпоинтом POST /api/admin/seed-demo?showcase=1
|
||||
# (см. src/food-market.api/Seed/ShowcaseSeeder.cs). Деплой стенда: ~/deploy-stage.sh.
|
||||
#
|
||||
# Запуск:
|
||||
# bash deploy/showcase-seed.sh
|
||||
# BASE=https://test.admin.food-market.kz bash deploy/showcase-seed.sh
|
||||
#
|
||||
# На выходе печатает orgId и e-mail владельца (их же использует scripts/screenshots.mjs
|
||||
# через SHOWCASE_EMAIL/SHOWCASE_ORG).
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${BASE:-https://test.admin.food-market.kz}"
|
||||
EMAIL="${SHOWCASE_EMAIL:-vladelec@magazin-u-doma.kz}"
|
||||
PASSWORD="${SHOWCASE_PASSWORD:-MagazinUDoma2026!}"
|
||||
ORGNAME="${SHOWCASE_ORG:-Магазин у дома}"
|
||||
PHONE="+77015550102"
|
||||
|
||||
j() { python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get(sys.argv[1],""))' "$1"; }
|
||||
|
||||
echo "→ стенд: $BASE организация: «$ORGNAME» владелец: $EMAIL"
|
||||
|
||||
# 1. Регистрация организации (идемпотентно: если e-mail занят — просто логинимся).
|
||||
echo "→ signup…"
|
||||
signup=$(curl -sk -m 60 -X POST "$BASE/api/auth/signup" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"organizationName\":\"$ORGNAME\",\"phone\":\"$PHONE\",\"plan\":\"start\"}" \
|
||||
-w '\n%{http_code}')
|
||||
code=$(echo "$signup" | tail -1)
|
||||
body=$(echo "$signup" | sed '$d')
|
||||
if [[ "$code" == "200" ]]; then
|
||||
echo " создана новая организация."
|
||||
elif echo "$body" | grep -q "уже зарегистрирован"; then
|
||||
echo " организация уже существует — переиспользуем."
|
||||
elif [[ "$code" == "429" ]]; then
|
||||
echo " rate-limit на signup, ждём 15с и повторяем…"
|
||||
sleep 15
|
||||
curl -sk -m 60 -X POST "$BASE/api/auth/signup" -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"organizationName\":\"$ORGNAME\",\"phone\":\"$PHONE\",\"plan\":\"start\"}" >/dev/null || true
|
||||
else
|
||||
echo " signup вернул $code: $body" >&2
|
||||
echo " (продолжаем — возможно, организация уже заведена)"
|
||||
fi
|
||||
|
||||
# 2. Токен владельца.
|
||||
echo "→ получаем токен…"
|
||||
TOKEN=$(curl -sk -m 60 -X POST "$BASE/connect/token" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'grant_type=password' \
|
||||
--data-urlencode "username=$EMAIL" \
|
||||
--data-urlencode "password=$PASSWORD" \
|
||||
--data-urlencode 'client_id=food-market-web' \
|
||||
--data-urlencode 'scope=openid profile email roles api offline_access' \
|
||||
| j access_token)
|
||||
if [[ -z "$TOKEN" ]]; then echo "✗ не удалось получить токен" >&2; exit 1; fi
|
||||
|
||||
ORGID=$(curl -sk -m 30 "$BASE/api/me" -H "Authorization: Bearer $TOKEN" | j orgId)
|
||||
echo " orgId=$ORGID"
|
||||
|
||||
# 3. Наполнение витринными данными.
|
||||
echo "→ seed showcase (товары/продажи/счета/сотрудники)…"
|
||||
resp=$(curl -sk -m 300 -X POST "$BASE/api/admin/seed-demo?showcase=1" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Length: 0')
|
||||
echo "$resp" | python3 -m json.tool 2>/dev/null || echo "$resp"
|
||||
|
||||
echo
|
||||
echo "✓ готово. Для скриншотов:"
|
||||
echo " SHOWCASE_EMAIL=$EMAIL SHOWCASE_PASSWORD=*** node src/food-market.public/scripts/screenshots.mjs"
|
||||
echo " orgId=$ORGID"
|
||||
26
docs/site-screenshots-progress.md
Normal file
26
docs/site-screenshots-progress.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Обновление скриншотов публичного сайта (ALL MARKET)
|
||||
|
||||
Владелец 08.10: «скриншоты нужно ещё обновить». Сейчас
|
||||
`src/food-market.public/public/screenshots/*.png` — от 26.04, старый интерфейс со старым
|
||||
логотипом Food Market. Нужны свежие скриншоты интерфейса ALL MARKET на правдоподобных
|
||||
витринных данных (без e2e/test-мусора).
|
||||
|
||||
## План / статус
|
||||
1. [ ] Витринная организация на стенде «Магазин у дома» (простой режим): ~200 реальных
|
||||
товаров (цены/штрихкоды/группы), 2–3 поставщика, неделя продаж, приёмки/списания,
|
||||
инвентаризация, счета (касса/Kaspi/эквайринг), сотрудники с обычными именами.
|
||||
Повторяемый скрипт — `deploy/showcase-seed.*`.
|
||||
2. [ ] Переснять `scripts/screenshots.mjs` в интерфейсе ALL MARKET (1440×900 @2x, светлая тема,
|
||||
без тостов/отладки, без чужих данных): дашборд, каталог, карточка товара, приёмка,
|
||||
остатки, инвентаризация, деньги/смены, отчёты, контрагенты.
|
||||
3. [ ] Касса: свежие скрины новой светлой кассы (продажа/оплата). Мобильное iOS — если есть
|
||||
`~/.fm-watchdog/brand/ios-screens/`.
|
||||
4. [ ] Подставить на сайт, оптимизировать (webp/png ≤300КБ, srcset), alt по-русски. OG — проверить.
|
||||
5. [ ] Выкладку публичного сайта добавить в `~/deploy-stage.sh` (через локальное зеркало образов).
|
||||
6. [ ] Выложить, проверить test.food-market.kz, галерея `docs/site-screenshots/index.html`.
|
||||
|
||||
## Что НЕ трогаем
|
||||
Прод (food-market.kz, admin.food-market.kz), shared-postgres, global.json, gateway nginx, sklad.
|
||||
|
||||
## Журнал
|
||||
- 08.10 — старт: план, обследование текущих скриншотов и страниц сайта.
|
||||
|
|
@ -33,15 +33,18 @@ public class DemoSeedController : ControllerBase
|
|||
{
|
||||
private readonly DemoTenantSeeder _shortSeeder;
|
||||
private readonly YearDemoSeeder _yearSeeder;
|
||||
private readonly ShowcaseSeeder _showcaseSeeder;
|
||||
private readonly ITenantContext _tenant;
|
||||
private readonly ILogger<DemoSeedController> _log;
|
||||
|
||||
public DemoSeedController(
|
||||
DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder,
|
||||
ShowcaseSeeder showcaseSeeder,
|
||||
ITenantContext tenant, ILogger<DemoSeedController> log)
|
||||
{
|
||||
_shortSeeder = shortSeeder;
|
||||
_yearSeeder = yearSeeder;
|
||||
_showcaseSeeder = showcaseSeeder;
|
||||
_tenant = tenant;
|
||||
_log = log;
|
||||
}
|
||||
|
|
@ -65,10 +68,25 @@ public async Task<ActionResult<object>> Status([FromQuery] int years = 0, Cancel
|
|||
/// возвращает existing summary без вставок. При <c>?years=1</c> — Year-demo
|
||||
/// (см. <see cref="YearDemoSeeder"/>): 200 товаров / 1500 продаж / год.</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<object>> Run([FromQuery] int years = 0, CancellationToken ct = default)
|
||||
public async Task<ActionResult<object>> Run([FromQuery] int years = 0, [FromQuery] int showcase = 0, CancellationToken ct = default)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId
|
||||
?? throw new InvalidOperationException("No tenant in context");
|
||||
if (showcase >= 1)
|
||||
{
|
||||
_log.LogInformation("Showcase seed requested for org={OrgId}", orgId);
|
||||
try
|
||||
{
|
||||
var result = await _showcaseSeeder.SeedAsync(orgId, ct);
|
||||
_log.LogInformation("Showcase seed done for org={OrgId}: products={Products} sales={Sales}",
|
||||
orgId, result.Products, result.Sales);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
if (years >= 1)
|
||||
{
|
||||
_log.LogInformation("Year-demo seed requested for org={OrgId}", orgId);
|
||||
|
|
|
|||
|
|
@ -521,6 +521,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
builder.Services.AddHostedService<foodmarket.Api.Seed.QuickLoginDemoSeeder>();
|
||||
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
||||
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
||||
// Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1).
|
||||
builder.Services.AddScoped<foodmarket.Api.Seed.ShowcaseSeeder>();
|
||||
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 —
|
||||
|
|
|
|||
424
src/food-market.api/Seed/ShowcaseSeeder.cs
Normal file
424
src/food-market.api/Seed/ShowcaseSeeder.cs
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Domain.Inventory;
|
||||
using foodmarket.Domain.Money;
|
||||
using foodmarket.Domain.Organizations;
|
||||
using foodmarket.Domain.Purchases;
|
||||
using foodmarket.Domain.Sales;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Seed;
|
||||
|
||||
/// <summary>«Витринный» сидер для скриншотов публичного сайта и демонстраций.
|
||||
///
|
||||
/// В отличие от <see cref="YearDemoSeeder"/> (маркер <c>Y1-</c> в артикулах и
|
||||
/// номерах — виден в UI и выглядит технически) и <see cref="DemoTenantSeeder"/>
|
||||
/// (всего 50 товаров), этот сидер наполняет организацию данными, которые на
|
||||
/// скриншоте выглядят как реальный работающий магазин:
|
||||
/// <list type="bullet">
|
||||
/// <item>200 товаров в 8 группах, <b>без артикулов-маркеров</b> (Article=null),
|
||||
/// реальные EAN-13, цены, НДС 12%;</item>
|
||||
/// <item>3 поставщика (ТОО/ИП), без служебных заметок;</item>
|
||||
/// <item>номера документов в «боевом» формате (<c>П-2026-000001</c>,
|
||||
/// <c>ПР-2026-000012</c>, <c>С-2026-000001</c>, <c>И-2026-000001</c>) —
|
||||
/// без <c>Y1-/DEMO-</c>;</item>
|
||||
/// <item>счета и кассы: «Касса магазина» (нал), «Kaspi» (банк), «Эквайринг»;</item>
|
||||
/// <item>сотрудники с нормальными именами (директор, кассир, кладовщик);</item>
|
||||
/// <item>~30 дней продаж с правдоподобными чеками, приёмки, 2 списания,
|
||||
/// 1 завершённая инвентаризация (модель МойСклада — без прямых движений).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// НИКАКИХ слов e2e/test/проверка/Y1/DEMO в видимых данных. Предназначен для
|
||||
/// свежей организации (например «Магазин у дома»), созданной через signup.
|
||||
/// Идемпотентен: если в орг. уже есть товары — возвращает текущую сводку без
|
||||
/// вставок. Скрипт создания — <c>deploy/showcase-seed.sh</c>.</summary>
|
||||
public sealed class ShowcaseSeeder
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
// Фиксированный seed → повторяемость набора между прогонами.
|
||||
private readonly Random _rng = new(20261008);
|
||||
|
||||
public ShowcaseSeeder(AppDbContext db) => _db = db;
|
||||
|
||||
public sealed record SeedSummary(
|
||||
bool AlreadySeeded,
|
||||
int Groups, int Products, int Counterparties,
|
||||
int Suppliers, int Supplies, int Sales,
|
||||
int Losses, int Inventories, int MoneyAccounts,
|
||||
int Employees, int StockMovements);
|
||||
|
||||
public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = default)
|
||||
{
|
||||
var alreadyHasProducts = await _db.Products.IgnoreQueryFilters()
|
||||
.AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||
if (alreadyHasProducts)
|
||||
{
|
||||
return await CountAsync(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 mainStore = await _db.Stores.IgnoreQueryFilters().FirstAsync(s => s.OrganizationId == orgId && s.IsMain, ct);
|
||||
var retailPoint = await _db.RetailPoints.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(rp => rp.OrganizationId == orgId, ct);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var yearNow = now.Year;
|
||||
|
||||
// ── 8 групп ─────────────────────────────────────────────────────────
|
||||
var grpNames = new[]
|
||||
{
|
||||
"Молочные продукты", "Хлеб и выпечка", "Напитки", "Бакалея",
|
||||
"Снеки", "Овощи и фрукты", "Мясо и птица", "Замороженные продукты",
|
||||
};
|
||||
var groups = grpNames.Select(name => new ProductGroup
|
||||
{
|
||||
OrganizationId = orgId, Name = name, ParentId = null,
|
||||
Path = name, SortOrder = 10, MarkupPercent = 30m,
|
||||
}).ToList();
|
||||
_db.ProductGroups.AddRange(groups);
|
||||
|
||||
// ── 200 товаров (чистые: без Article-маркера, реальные EAN-13) ───────
|
||||
var products = new List<Product>();
|
||||
long bcSeq = 487_600_000_001L;
|
||||
for (int g = 0; g < groups.Count; g++)
|
||||
{
|
||||
var (priceMin, priceMax, unit, packaging) = YearDemoSeeder.GroupParams(g);
|
||||
var grpUnit = unit switch { "kg" => unitKg, "l" => unitL, _ => unitSht };
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
var name = YearDemoSeeder.ProductNames[g][i];
|
||||
var raw = priceMin + (decimal)_rng.NextDouble() * (priceMax - priceMin);
|
||||
var price = Math.Round(raw / 10m, 0) * 10m; // до 10 тенге
|
||||
var p = new Product
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Name = name,
|
||||
Article = null,
|
||||
UnitOfMeasureId = grpUnit.Id,
|
||||
Vat = 12m, VatEnabled = true,
|
||||
ProductGroupId = groups[g].Id,
|
||||
Packaging = packaging,
|
||||
ReferencePrice = price,
|
||||
Cost = Math.Round(price * 0.6m, 2),
|
||||
Prices = new List<ProductPrice>
|
||||
{
|
||||
new() {
|
||||
OrganizationId = orgId, PriceTypeId = retailPrice.Id,
|
||||
Amount = price, CurrencyId = currency.Id,
|
||||
},
|
||||
},
|
||||
Barcodes = new List<ProductBarcode>
|
||||
{
|
||||
new() {
|
||||
OrganizationId = orgId, Code = YearDemoSeeder.MakeEan13(bcSeq++),
|
||||
Type = BarcodeType.Ean13, IsPrimary = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
_db.Products.Add(p);
|
||||
products.Add(p);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3 поставщика (без служебных заметок) ─────────────────────────────
|
||||
var supplierNames = new[]
|
||||
{
|
||||
"ТОО «Алматы Фуд»", "ТОО «Кока-Кола Алматы Боттлерс»", "ИП Бегимбаев К.Т.",
|
||||
};
|
||||
var suppliers = new List<Counterparty>();
|
||||
foreach (var name in supplierNames)
|
||||
{
|
||||
var cp = new Counterparty
|
||||
{
|
||||
OrganizationId = orgId, Name = name, LegalName = name,
|
||||
Type = CounterpartyType.LegalEntity,
|
||||
Bin = YearDemoSeeder.MakeBin(bcSeq++),
|
||||
Phone = $"+7 (727) 3{_rng.Next(10, 99)}-{_rng.Next(10, 99)}-{_rng.Next(10, 99)}",
|
||||
};
|
||||
suppliers.Add(cp);
|
||||
_db.Counterparties.Add(cp);
|
||||
}
|
||||
|
||||
// ── Счета и кассы ────────────────────────────────────────────────────
|
||||
var moneyAccounts = new List<MoneyAccount>
|
||||
{
|
||||
new()
|
||||
{
|
||||
OrganizationId = orgId, Name = "Касса магазина", Kind = MoneyAccountKind.Cash,
|
||||
StoreId = mainStore.Id, CurrencyId = currency.Id,
|
||||
OpeningBalance = 85_000m, OpeningDate = now.AddDays(-30),
|
||||
IsActive = true, IsDefault = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
OrganizationId = orgId, Name = "Kaspi", Kind = MoneyAccountKind.Bank,
|
||||
BankName = "Kaspi Bank", AccountNumber = "KZ86 7220 0000 0012 3456",
|
||||
CurrencyId = currency.Id,
|
||||
OpeningBalance = 1_250_000m, OpeningDate = now.AddDays(-30),
|
||||
IsActive = true, IsDefault = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
OrganizationId = orgId, Name = "Эквайринг", Kind = MoneyAccountKind.Acquiring,
|
||||
BankName = "Halyk Bank", AccountNumber = "KZ49 3490 0000 0098 7654",
|
||||
StoreId = mainStore.Id, CurrencyId = currency.Id,
|
||||
OpeningBalance = 0m, OpeningDate = now.AddDays(-30),
|
||||
IsActive = true, IsDefault = true,
|
||||
},
|
||||
};
|
||||
_db.MoneyAccounts.AddRange(moneyAccounts);
|
||||
|
||||
// ── Сотрудники с нормальными именами ─────────────────────────────────
|
||||
var roles = await _db.EmployeeRoles.IgnoreQueryFilters()
|
||||
.Where(r => r.OrganizationId == orgId).ToListAsync(ct);
|
||||
var roleDirector = roles.FirstOrDefault(r => r.Name == "Администратор") ?? roles.First();
|
||||
var roleCashier = roles.FirstOrDefault(r => r.Name == "Кассир") ?? roleDirector;
|
||||
var roleKeeper = roles.FirstOrDefault(r => r.Name == "Кладовщик") ?? roleDirector;
|
||||
var employees = new List<Employee>
|
||||
{
|
||||
new()
|
||||
{
|
||||
OrganizationId = orgId, LastName = "Сапарова", FirstName = "Айгуль", MiddleName = "Ержановна",
|
||||
Position = "Директор магазина", Phone = "+7 (701) 555-10-20",
|
||||
RoleId = roleDirector.Id, IsActive = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
OrganizationId = orgId, LastName = "Ким", FirstName = "Виктория", MiddleName = "Сергеевна",
|
||||
Position = "Старший кассир", Phone = "+7 (707) 441-32-18",
|
||||
RoleId = roleCashier.Id, IsActive = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
OrganizationId = orgId, LastName = "Нурланов", FirstName = "Данияр", MiddleName = "Болатович",
|
||||
Position = "Кассир", Phone = "+7 (747) 220-76-54",
|
||||
RoleId = roleCashier.Id, IsActive = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
OrganizationId = orgId, LastName = "Оспанов", FirstName = "Марат", MiddleName = "Қайратұлы",
|
||||
Position = "Кладовщик", Phone = "+7 (775) 318-09-42",
|
||||
RoleId = roleKeeper.Id, IsActive = true,
|
||||
},
|
||||
};
|
||||
_db.Employees.AddRange(employees);
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
// Накопитель движений (bulk в конце, один пересчёт агрегата Stocks).
|
||||
var movements = new List<StockMovement>();
|
||||
|
||||
// ── Приёмки: 6 поставок за последние 30 дней ─────────────────────────
|
||||
var supplyCount = 6;
|
||||
var supplies = new List<Supply>();
|
||||
for (int i = 0; i < supplyCount; i++)
|
||||
{
|
||||
var date = now.AddDays(-28 + i * 4 - _rng.NextDouble());
|
||||
var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(30, 60)).ToList();
|
||||
var supply = new Supply
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Number = $"П-{yearNow}-{i + 1:D6}",
|
||||
Date = date, Status = SupplyStatus.Posted, PostedAt = date,
|
||||
SupplierId = suppliers[i % suppliers.Count].Id,
|
||||
StoreId = mainStore.Id, CurrencyId = currency.Id,
|
||||
};
|
||||
decimal total = 0;
|
||||
int order = 0;
|
||||
foreach (var p in picked)
|
||||
{
|
||||
var qty = _rng.Next(20, 80);
|
||||
var price = Math.Round(p.ReferencePrice!.Value * 0.6m, 2);
|
||||
var lt = qty * price;
|
||||
supply.Lines.Add(new SupplyLine
|
||||
{
|
||||
OrganizationId = orgId, ProductId = p.Id, Quantity = qty, UnitPrice = price,
|
||||
LineTotal = lt, SortOrder = order++,
|
||||
});
|
||||
total += lt;
|
||||
movements.Add(new StockMovement
|
||||
{
|
||||
OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id,
|
||||
Quantity = qty, UnitCost = price, Type = MovementType.Supply,
|
||||
DocumentType = "supply", DocumentId = supply.Id, DocumentNumber = supply.Number,
|
||||
OccurredAt = date,
|
||||
});
|
||||
p.LastSupplyAt = date;
|
||||
}
|
||||
supply.Total = total;
|
||||
_db.Supplies.Add(supply);
|
||||
supplies.Add(supply);
|
||||
}
|
||||
|
||||
// ── Продажи: ~30 дней, 18-38 чеков в день ────────────────────────────
|
||||
var sales = new List<RetailSale>();
|
||||
int saleNo = 1;
|
||||
for (int d = 29; d >= 0; d--)
|
||||
{
|
||||
var day = now.AddDays(-d);
|
||||
// будни меньше, пт-вс больше — лёгкая правдоподобная динамика
|
||||
var baseCount = day.DayOfWeek is DayOfWeek.Friday or DayOfWeek.Saturday or DayOfWeek.Sunday ? 34 : 24;
|
||||
var count = baseCount + _rng.Next(-6, 7);
|
||||
for (int s = 0; s < count; s++)
|
||||
{
|
||||
var date = day.Date.AddHours(_rng.Next(8, 22)).AddMinutes(_rng.Next(0, 60));
|
||||
var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(1, 7)).ToList();
|
||||
var sale = new RetailSale
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Number = $"ПР-{yearNow}-{saleNo++:D6}",
|
||||
Date = date, Status = RetailSaleStatus.Posted, PostedAt = date,
|
||||
StoreId = mainStore.Id, RetailPointId = retailPoint?.Id,
|
||||
CurrencyId = currency.Id,
|
||||
Payment = _rng.NextDouble() < 0.6 ? PaymentMethod.Card : PaymentMethod.Cash,
|
||||
IsReturn = false,
|
||||
};
|
||||
decimal subtotal = 0;
|
||||
int order = 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, Discount = 0, LineTotal = lt,
|
||||
VatPercent = 12, SortOrder = order++,
|
||||
});
|
||||
subtotal += lt;
|
||||
movements.Add(new StockMovement
|
||||
{
|
||||
OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id,
|
||||
Quantity = -qty, UnitCost = p.Cost, Type = MovementType.RetailSale,
|
||||
DocumentType = "retail-sale", DocumentId = sale.Id, DocumentNumber = sale.Number,
|
||||
OccurredAt = date,
|
||||
});
|
||||
}
|
||||
sale.Subtotal = subtotal;
|
||||
sale.Total = subtotal;
|
||||
if (sale.Payment == PaymentMethod.Cash) sale.PaidCash = subtotal;
|
||||
else sale.PaidCard = subtotal;
|
||||
_db.RetailSales.Add(sale);
|
||||
sales.Add(sale);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2 списания (просрочка/бой) ───────────────────────────────────────
|
||||
var losses = new List<Loss>();
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var date = now.AddDays(-14 + i * 9);
|
||||
var p = products[_rng.Next(products.Count)];
|
||||
var qty = _rng.Next(2, 6);
|
||||
var unitCost = p.Cost;
|
||||
var l = new Loss
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Number = $"С-{yearNow}-{i + 1:D6}",
|
||||
Date = date, Status = LossStatus.Posted, PostedAt = date,
|
||||
StoreId = mainStore.Id, CurrencyId = currency.Id,
|
||||
Reason = i % 2 == 0 ? LossReason.Expired : LossReason.Damage,
|
||||
Total = unitCost * qty,
|
||||
};
|
||||
l.Lines.Add(new LossLine
|
||||
{
|
||||
OrganizationId = orgId, ProductId = p.Id, Quantity = qty,
|
||||
UnitCost = unitCost, LineTotal = unitCost * qty, SortOrder = 0,
|
||||
});
|
||||
movements.Add(new StockMovement
|
||||
{
|
||||
OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id,
|
||||
Quantity = -qty, UnitCost = unitCost, Type = MovementType.WriteOff,
|
||||
DocumentType = "loss", DocumentId = l.Id, DocumentNumber = l.Number,
|
||||
OccurredAt = date,
|
||||
});
|
||||
_db.Losses.Add(l);
|
||||
losses.Add(l);
|
||||
}
|
||||
|
||||
// ── 1 завершённая инвентаризация (модель МойСклада: без движений) ─────
|
||||
var inventories = new List<InventoryDoc>();
|
||||
{
|
||||
var date = now.AddDays(-3);
|
||||
var picked = products.OrderBy(_ => _rng.Next()).Take(8).ToList();
|
||||
var inv = new InventoryDoc
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
Number = $"И-{yearNow}-{1:D6}",
|
||||
Date = date, Status = InventoryStatus.Completed, PostedAt = date,
|
||||
StoreId = mainStore.Id,
|
||||
};
|
||||
int order = 0;
|
||||
foreach (var p in picked)
|
||||
{
|
||||
var book = _rng.Next(20, 80);
|
||||
var dq = _rng.Next(-2, 3);
|
||||
var actual = Math.Max(0, book + dq);
|
||||
inv.Lines.Add(new InventoryLine
|
||||
{
|
||||
OrganizationId = orgId, InventoryDocId = inv.Id, ProductId = p.Id,
|
||||
BookQty = book, ActualQty = actual, Diff = actual - book,
|
||||
UnitCost = p.Cost, SortOrder = order++,
|
||||
});
|
||||
}
|
||||
_db.InventoryDocs.Add(inv);
|
||||
inventories.Add(inv);
|
||||
}
|
||||
|
||||
// ── Bulk-движения + пересчёт агрегата Stocks ─────────────────────────
|
||||
_db.StockMovements.AddRange(movements);
|
||||
var stockAgg = movements
|
||||
.GroupBy(m => (m.ProductId, m.StoreId))
|
||||
.Select(g => new { g.Key.ProductId, g.Key.StoreId, Quantity = g.Sum(m => m.Quantity) })
|
||||
.ToList();
|
||||
foreach (var s in stockAgg)
|
||||
{
|
||||
_db.Stocks.Add(new Stock
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
ProductId = s.ProductId, StoreId = s.StoreId,
|
||||
Quantity = s.Quantity, ReservedQuantity = 0,
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
return new SeedSummary(
|
||||
AlreadySeeded: false,
|
||||
Groups: groups.Count, Products: products.Count, Counterparties: suppliers.Count,
|
||||
Suppliers: suppliers.Count, Supplies: supplies.Count, Sales: sales.Count,
|
||||
Losses: losses.Count, Inventories: inventories.Count,
|
||||
MoneyAccounts: moneyAccounts.Count, Employees: employees.Count,
|
||||
StockMovements: movements.Count);
|
||||
}
|
||||
|
||||
public async Task<SeedSummary> PeekAsync(Guid orgId, CancellationToken ct)
|
||||
{
|
||||
var has = await _db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||
return (await CountAsync(orgId, ct)) with { AlreadySeeded = has };
|
||||
}
|
||||
|
||||
private async Task<SeedSummary> CountAsync(Guid orgId, CancellationToken ct)
|
||||
{
|
||||
var prod = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == orgId, ct);
|
||||
var grp = await _db.ProductGroups.IgnoreQueryFilters().CountAsync(g => g.OrganizationId == orgId, ct);
|
||||
var cp = await _db.Counterparties.IgnoreQueryFilters().CountAsync(c => c.OrganizationId == orgId, ct);
|
||||
var sup = await _db.Supplies.IgnoreQueryFilters().CountAsync(s => s.OrganizationId == orgId, ct);
|
||||
var sales = await _db.RetailSales.IgnoreQueryFilters().CountAsync(s => s.OrganizationId == orgId && !s.IsReturn, ct);
|
||||
var loss = await _db.Losses.IgnoreQueryFilters().CountAsync(l => l.OrganizationId == orgId, ct);
|
||||
var inv = await _db.InventoryDocs.IgnoreQueryFilters().CountAsync(i => i.OrganizationId == orgId, ct);
|
||||
var acc = await _db.MoneyAccounts.IgnoreQueryFilters().CountAsync(a => a.OrganizationId == orgId, ct);
|
||||
var emp = await _db.Employees.IgnoreQueryFilters().CountAsync(e => e.OrganizationId == orgId, ct);
|
||||
var mov = await _db.StockMovements.IgnoreQueryFilters().CountAsync(m => m.OrganizationId == orgId, ct);
|
||||
return new SeedSummary(true, grp, prod, cp, cp, sup, sales, loss, inv, acc, emp, mov);
|
||||
}
|
||||
}
|
||||
|
|
@ -549,7 +549,7 @@ private async Task<SeedSummary> CountAsync(Guid orgId, CancellationToken ct)
|
|||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static (decimal Min, decimal Max, string Unit, Packaging Pack) GroupParams(int g) => g switch
|
||||
internal static (decimal Min, decimal Max, string Unit, Packaging Pack) GroupParams(int g) => g switch
|
||||
{
|
||||
0 => (200, 1200, "sht", Packaging.Piece), // молочка
|
||||
1 => (80, 450, "sht", Packaging.Piece), // хлеб/выпечка
|
||||
|
|
@ -561,7 +561,7 @@ private async Task<SeedSummary> CountAsync(Guid orgId, CancellationToken ct)
|
|||
_ => (350, 2200, "sht", Packaging.Piece), // заморозка
|
||||
};
|
||||
|
||||
private static string MakeEan13(long body12)
|
||||
internal static string MakeEan13(long body12)
|
||||
{
|
||||
var s = body12.ToString("D12");
|
||||
var sum = 0;
|
||||
|
|
@ -573,9 +573,9 @@ private static string MakeEan13(long body12)
|
|||
return s + (10 - sum % 10) % 10;
|
||||
}
|
||||
|
||||
private static string MakeBin(long seed) => $"1234{seed % 100000000:D8}"[..12];
|
||||
internal static string MakeBin(long seed) => $"1234{seed % 100000000:D8}"[..12];
|
||||
|
||||
private static readonly string[] SupplierNames =
|
||||
internal static readonly string[] SupplierNames =
|
||||
{
|
||||
"ТОО «Алматы Фуд»", "ТОО «Карагандинский молокозавод»", "ИП Бегимбаев К.Т.",
|
||||
"ТОО «Хлебокомбинат Астана»", "ТОО «Шымкент Дистрибьюшн»",
|
||||
|
|
@ -586,7 +586,7 @@ private static string MakeEan13(long body12)
|
|||
"ТОО «Кондитерская фабрика Рахат»",
|
||||
};
|
||||
|
||||
private static readonly string[] CustomerNames =
|
||||
internal static readonly string[] CustomerNames =
|
||||
{
|
||||
"ТОО «Сеть кафе Бариста»", "ТОО «Школа-лицей №5»", "ТОО «Гостиница Алтын»",
|
||||
"ТОО «Корпоративное питание KZ»", "ТОО «Сеть пекарен Самал»",
|
||||
|
|
@ -598,7 +598,7 @@ private static string MakeEan13(long body12)
|
|||
};
|
||||
|
||||
// 8 × 25 = 200 названий товаров. Реалистичные категории.
|
||||
private static readonly string[][] ProductNames =
|
||||
internal static readonly string[][] ProductNames =
|
||||
{
|
||||
// 0: Молочные
|
||||
new[] {
|
||||
|
|
|
|||
Loading…
Reference in a new issue