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

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:
nns 2026-10-08 21:46:45 +05:00
parent db8cd637e5
commit 9ddbffc04a
6 changed files with 551 additions and 7 deletions

74
deploy/showcase-seed.sh Executable file
View 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"

View 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 — старт: план, обследование текущих скриншотов и страниц сайта.

View file

@ -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);

View file

@ -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 —

View 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);
}
}

View file

@ -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[] {