From 9ddbffc04a4432fdbbea0afce53510e4102e6b1a Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 21:46:45 +0500 Subject: [PATCH] =?UTF-8?q?feat(seed):=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81=D0=B8=D0=B4=D0=B5=D1=80=20?= =?UTF-8?q?=C2=AB=D0=9C=D0=B0=D0=B3=D0=B0=D0=B7=D0=B8=D0=BD=20=D1=83=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=B0=C2=BB=20=D0=B4=D0=BB=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BD=D1=88=D0=BE=D1=82=D0=BE=D0=B2=20=D1=81?= =?UTF-8?q?=D0=B0=D0=B9=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/showcase-seed.sh | 74 +++ docs/site-screenshots-progress.md | 26 ++ .../Controllers/Admin/DemoSeedController.cs | 20 +- src/food-market.api/Program.cs | 2 + src/food-market.api/Seed/ShowcaseSeeder.cs | 424 ++++++++++++++++++ src/food-market.api/Seed/YearDemoSeeder.cs | 12 +- 6 files changed, 551 insertions(+), 7 deletions(-) create mode 100755 deploy/showcase-seed.sh create mode 100644 docs/site-screenshots-progress.md create mode 100644 src/food-market.api/Seed/ShowcaseSeeder.cs diff --git a/deploy/showcase-seed.sh b/deploy/showcase-seed.sh new file mode 100755 index 0000000..e93966a --- /dev/null +++ b/deploy/showcase-seed.sh @@ -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" diff --git a/docs/site-screenshots-progress.md b/docs/site-screenshots-progress.md new file mode 100644 index 0000000..70990b6 --- /dev/null +++ b/docs/site-screenshots-progress.md @@ -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 — старт: план, обследование текущих скриншотов и страниц сайта. diff --git a/src/food-market.api/Controllers/Admin/DemoSeedController.cs b/src/food-market.api/Controllers/Admin/DemoSeedController.cs index 80eb2e7..b2da63d 100644 --- a/src/food-market.api/Controllers/Admin/DemoSeedController.cs +++ b/src/food-market.api/Controllers/Admin/DemoSeedController.cs @@ -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 _log; public DemoSeedController( DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder, + ShowcaseSeeder showcaseSeeder, ITenantContext tenant, ILogger log) { _shortSeeder = shortSeeder; _yearSeeder = yearSeeder; + _showcaseSeeder = showcaseSeeder; _tenant = tenant; _log = log; } @@ -65,10 +68,25 @@ public async Task> Status([FromQuery] int years = 0, Cancel /// возвращает existing summary без вставок. При ?years=1 — Year-demo /// (см. ): 200 товаров / 1500 продаж / год. [HttpPost] - public async Task> Run([FromQuery] int years = 0, CancellationToken ct = default) + public async Task> 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); diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 5186264..2104ce4 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -521,6 +521,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddHostedService(); // Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1). builder.Services.AddScoped(); + // Витринный сидер для скриншотов сайта (POST /api/admin/seed-demo?showcase=1). + builder.Services.AddScoped(); builder.Services.AddHostedService(); // DemoCatalogSeeder disabled: real catalog is imported from MoySklad. // Keep the file as reference for anyone starting without MoySklad access — diff --git a/src/food-market.api/Seed/ShowcaseSeeder.cs b/src/food-market.api/Seed/ShowcaseSeeder.cs new file mode 100644 index 0000000..3d53faa --- /dev/null +++ b/src/food-market.api/Seed/ShowcaseSeeder.cs @@ -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; + +/// «Витринный» сидер для скриншотов публичного сайта и демонстраций. +/// +/// В отличие от (маркер Y1- в артикулах и +/// номерах — виден в UI и выглядит технически) и +/// (всего 50 товаров), этот сидер наполняет организацию данными, которые на +/// скриншоте выглядят как реальный работающий магазин: +/// +/// 200 товаров в 8 группах, без артикулов-маркеров (Article=null), +/// реальные EAN-13, цены, НДС 12%; +/// 3 поставщика (ТОО/ИП), без служебных заметок; +/// номера документов в «боевом» формате (П-2026-000001, +/// ПР-2026-000012, С-2026-000001, И-2026-000001) — +/// без Y1-/DEMO-; +/// счета и кассы: «Касса магазина» (нал), «Kaspi» (банк), «Эквайринг»; +/// сотрудники с нормальными именами (директор, кассир, кладовщик); +/// ~30 дней продаж с правдоподобными чеками, приёмки, 2 списания, +/// 1 завершённая инвентаризация (модель МойСклада — без прямых движений). +/// +/// +/// НИКАКИХ слов e2e/test/проверка/Y1/DEMO в видимых данных. Предназначен для +/// свежей организации (например «Магазин у дома»), созданной через signup. +/// Идемпотентен: если в орг. уже есть товары — возвращает текущую сводку без +/// вставок. Скрипт создания — deploy/showcase-seed.sh. +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 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(); + 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 + { + new() { + OrganizationId = orgId, PriceTypeId = retailPrice.Id, + Amount = price, CurrencyId = currency.Id, + }, + }, + Barcodes = new List + { + 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(); + 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 + { + 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 + { + 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(); + + // ── Приёмки: 6 поставок за последние 30 дней ───────────────────────── + var supplyCount = 6; + var supplies = new List(); + 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(); + 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(); + 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(); + { + 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 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 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); + } +} diff --git a/src/food-market.api/Seed/YearDemoSeeder.cs b/src/food-market.api/Seed/YearDemoSeeder.cs index 2ffb10a..813dd80 100644 --- a/src/food-market.api/Seed/YearDemoSeeder.cs +++ b/src/food-market.api/Seed/YearDemoSeeder.cs @@ -549,7 +549,7 @@ private async Task 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 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[] {