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 DemoTenantSeeder _shortSeeder;
|
||||||
private readonly YearDemoSeeder _yearSeeder;
|
private readonly YearDemoSeeder _yearSeeder;
|
||||||
|
private readonly ShowcaseSeeder _showcaseSeeder;
|
||||||
private readonly ITenantContext _tenant;
|
private readonly ITenantContext _tenant;
|
||||||
private readonly ILogger<DemoSeedController> _log;
|
private readonly ILogger<DemoSeedController> _log;
|
||||||
|
|
||||||
public DemoSeedController(
|
public DemoSeedController(
|
||||||
DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder,
|
DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder,
|
||||||
|
ShowcaseSeeder showcaseSeeder,
|
||||||
ITenantContext tenant, ILogger<DemoSeedController> log)
|
ITenantContext tenant, ILogger<DemoSeedController> log)
|
||||||
{
|
{
|
||||||
_shortSeeder = shortSeeder;
|
_shortSeeder = shortSeeder;
|
||||||
_yearSeeder = yearSeeder;
|
_yearSeeder = yearSeeder;
|
||||||
|
_showcaseSeeder = showcaseSeeder;
|
||||||
_tenant = tenant;
|
_tenant = tenant;
|
||||||
_log = log;
|
_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
|
/// возвращает existing summary без вставок. При <c>?years=1</c> — Year-demo
|
||||||
/// (см. <see cref="YearDemoSeeder"/>): 200 товаров / 1500 продаж / год.</summary>
|
/// (см. <see cref="YearDemoSeeder"/>): 200 товаров / 1500 продаж / год.</summary>
|
||||||
[HttpPost]
|
[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
|
var orgId = _tenant.OrganizationId
|
||||||
?? throw new InvalidOperationException("No tenant in context");
|
?? 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)
|
if (years >= 1)
|
||||||
{
|
{
|
||||||
_log.LogInformation("Year-demo seed requested for org={OrgId}", orgId);
|
_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>();
|
builder.Services.AddHostedService<foodmarket.Api.Seed.QuickLoginDemoSeeder>();
|
||||||
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
||||||
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
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>();
|
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 —
|
||||||
|
|
|
||||||
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 ─────────────────────────────────────────────────────────────
|
// ── 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), // молочка
|
0 => (200, 1200, "sht", Packaging.Piece), // молочка
|
||||||
1 => (80, 450, "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), // заморозка
|
_ => (350, 2200, "sht", Packaging.Piece), // заморозка
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string MakeEan13(long body12)
|
internal static string MakeEan13(long body12)
|
||||||
{
|
{
|
||||||
var s = body12.ToString("D12");
|
var s = body12.ToString("D12");
|
||||||
var sum = 0;
|
var sum = 0;
|
||||||
|
|
@ -573,9 +573,9 @@ private static string MakeEan13(long body12)
|
||||||
return s + (10 - sum % 10) % 10;
|
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»", "ТОО «Гостиница Алтын»",
|
"ТОО «Сеть кафе Бариста»", "ТОО «Школа-лицей №5»", "ТОО «Гостиница Алтын»",
|
||||||
"ТОО «Корпоративное питание KZ»", "ТОО «Сеть пекарен Самал»",
|
"ТОО «Корпоративное питание KZ»", "ТОО «Сеть пекарен Самал»",
|
||||||
|
|
@ -598,7 +598,7 @@ private static string MakeEan13(long body12)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 8 × 25 = 200 названий товаров. Реалистичные категории.
|
// 8 × 25 = 200 названий товаров. Реалистичные категории.
|
||||||
private static readonly string[][] ProductNames =
|
internal static readonly string[][] ProductNames =
|
||||||
{
|
{
|
||||||
// 0: Молочные
|
// 0: Молочные
|
||||||
new[] {
|
new[] {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue