From 1044818fbb59346bc6fbf82ee828cad51ab221e4 Mon Sep 17 00:00:00 2001 From: nns Date: Sat, 6 Jun 2026 01:03:36 +0500 Subject: [PATCH] =?UTF-8?q?feat(s10):=20year-demo=20seeder=20+=204=20dashb?= =?UTF-8?q?oard=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D0=B0=20+=20week-st?= =?UTF-8?q?ats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S10-1: YearDemoSeeder — POST /api/admin/seed-demo?years=1. - 8 групп × 25 товаров = 200, 30 контрагентов, 80 приёмок равномерно по году, 1500 розничных продаж с месячной сезонностью (Dec пик ×1.6, Jul-Aug спад ×0.7), 20 customer-returns, 8 demands, 10 losses, 3 transfers, 5 inventories. - Маркер артикулов Y1- (параллельно с DEMO-короткий сидер). Гард на существующую активность чтобы не лить хаос поверх ручной работы. - Bulk StockMovement + переагрегация Stocks в конце транзакции — 16.5s на dev-vm vs 60+s если бы per-document SaveChanges. S10-2: DashboardController + 4 виджета: - GET /api/dashboard/top-products?days&limit — top-N по gross-выручке (без net-вычета returns; для точного есть /api/reports/sales). - GET /api/dashboard/low-stock?limit — Stock.Quantity ≤ Product.MinStock. - GET /api/dashboard/recent-sales?limit — последние N посt'ed чеков. - GET /api/dashboard/margin?days — Σ(LineTotal) - Σ(qty × Product.Cost), marginPercent к выручке. - /api/sales/retail/stats расширен revenueThisWeek + transactionsThisWeek. - Frontend: components/DashboardWidgets.tsx с 4 виджетами через React.lazy + Suspense. SignalR SalePosted инвалидирует все 4. - KPI блок: today / week / month + avg-ticket (4 плитки, prev-month стал delta на month-плитке). Проверено на стэйдже год-демо: top-5 за 365 дн. — «Колбаса сервелат 300г» 286440 ₸ / 32 транзакции. Margin 40% за 30 дн. Co-Authored-By: Claude Opus 4.7 --- docs/sprint10-progress.md | 55 ++ .../Controllers/Admin/DemoSeedController.cs | 75 +- .../Dashboard/DashboardController.cs | 170 +++++ .../Sales/RetailSalesController.cs | 16 +- src/food-market.api/Program.cs | 2 + src/food-market.api/Seed/YearDemoSeeder.cs | 712 ++++++++++++++++++ .../src/components/DashboardWidgets.tsx | 268 +++++++ src/food-market.web/src/lib/types.ts | 46 ++ .../src/pages/DashboardPage.tsx | 50 +- 9 files changed, 1363 insertions(+), 31 deletions(-) create mode 100644 docs/sprint10-progress.md create mode 100644 src/food-market.api/Controllers/Dashboard/DashboardController.cs create mode 100644 src/food-market.api/Seed/YearDemoSeeder.cs create mode 100644 src/food-market.web/src/components/DashboardWidgets.tsx diff --git a/docs/sprint10-progress.md b/docs/sprint10-progress.md new file mode 100644 index 0000000..9452469 --- /dev/null +++ b/docs/sprint10-progress.md @@ -0,0 +1,55 @@ +# Sprint 10 — расширенный seed + UX-полировка + +Цель: реалистичные год-данные для отчётов + дашборд-виджеты + +глобальный Cmd+K-поиск + dark-mode полировка. + +Старт: 2026-06-06. Исполнитель: Claude Opus 4.7 (автономный режим). + +## Принципы + +- Multi-tenant обязателен. +- Каждый пункт: build + локальные тесты + `~/deploy-stage.sh` + retest + на `https://test.admin.food-market.kz`. +- НЕ трогать: `global.json`, прод-стек, POS WPF. + +## Чек-лист + +- [x] **1. Расширенный SeedDemoData --years=1** — `YearDemoSeeder.cs`, + POST `/api/admin/seed-demo?years=1`. Маркер `Y1-`, идемпотентен, + жёсткий гард «tenant has activity» когда уже есть Supply/RetailSale. + Реально создаёт: 8 групп / 200 товаров (25 на группу) / 30 контрагентов + (15 поставщиков + 15 покупателей) / 80 приёмок равномерно по году / + **1500 розничных продаж с месячной сезонностью** (Dec пик ×1.6, + Jul-Aug спад ×0.7..0.75) / 20 customer-returns / 8 wholesale-demands / + 10 списаний / 3 перемещения / 5 инвентаризаций. Stocks пересчитываются + bulk'ом из StockMovement (5535 шт.). 16.5s на dev-vm. Проверено: + Sales-stats показывает «revenuePrevMonth 789750», ABC даёт top + «Колбаса сервелат» класс A с 3.1% доли. +- [x] **2. Dashboard виджеты** — новый `DashboardController` с 4 endpoint'ами: + `/api/dashboard/top-products`, `/low-stock`, `/recent-sales`, `/margin`. + `SalesStatsResponse` расширен `revenueThisWeek/transactionsThisWeek`. + UI: `components/DashboardWidgets.tsx` — TopProductsWidget, LowStockWidget, + RecentSalesWidget, MarginWidget; все 4 lazy через `React.lazy` + Suspense + с Skeleton-плейсхолдером. SignalR `SalePosted` инвалидирует все 4 виджета + + sales-stats. KPI-блок переработан: today / week / month + avg-ticket + (вместо prev-month как отдельной плитки — теперь в delta на «month»). + Каталог-плитки (товары/контрагенты/склады/точки) остаются ниже. +- [ ] **3. Глобальный search Cmd+K** — палитра команд: товары/контрагенты/ + документы/страницы, подсветка совпадений, recent items. +- [ ] **4. Dark mode полировка** — найти страницы без `dark:`, добавить + Tailwind dark-префиксы, скрин до/после на топ-10 страниц. + +## Журнал + +### 2026-06-06 старт +Прошёл verify-sprint (78/78 stage-ui specs ✓), `~/.fm-watchdog/DONE` снят. +Поехали по чек-листу. + +### 2026-06-06 п.1 +`YearDemoSeeder` создан. Идемпотентен через маркер `Y1-`, жёсткий гард на +свежем tenant'е. Bulk stock-agg вместо per-document SaveChanges — 16.5s. + +### 2026-06-06 п.2 +4 dashboard endpoint'a + lazy виджеты + week-stats в существующем +`/api/sales/retail/stats`. Margin 40% на демо-данных, top-5 показывает +«Колбасу сервелат» лидером по году. diff --git a/src/food-market.api/Controllers/Admin/DemoSeedController.cs b/src/food-market.api/Controllers/Admin/DemoSeedController.cs index de7f7be..80eb2e7 100644 --- a/src/food-market.api/Controllers/Admin/DemoSeedController.cs +++ b/src/food-market.api/Controllers/Admin/DemoSeedController.cs @@ -6,52 +6,89 @@ namespace foodmarket.Api.Controllers.Admin; /// Заполнение тестового стейджа реалистичными демо-данными по запросу -/// admin'a текущего tenant'а. См. — там логика -/// (5 групп / 50 товаров / 10 контрагентов / документы). +/// admin'a текущего tenant'а. /// -/// Использование: с UI кнопка «Заполнить демо-данными» в OrganizationSettingsPage -/// (только когда счётчик товаров = 0 в идемпотентной проверке). +/// Два режима: +/// +/// Short demo (POST /api/admin/seed-demo без параметра) — +/// : 5 групп / 50 товаров / 10 контрагентов / +/// 5 приёмок / 30 продаж за последние 30 дней. Маркер DEMO-. +/// Year demo (POST /api/admin/seed-demo?years=1) — +/// : 8 групп / 200 товаров / 30 контрагентов / +/// 80 приёмок / 1500 продаж с сезонностью (Dec peak, Jul-Aug spada) / +/// 20 возвратов / 5 инвентаризаций / 10 списаний / 3 перемещения / +/// 8 отгрузок. Маркер Y1-. Доступен только на свежем tenant'е +/// (без активных Supply/RetailSale). +/// /// -/// Безопасность: только Admin'ы и SuperAdmin (override mode). Не использовать -/// на проде (UI кнопку прячем когда товары уже есть; но даже если нажмут — -/// идемпотентный маркер DEMO- защитит от дубля). +/// Использование: UI кнопка «Заполнить демо-данными» в OrganizationSettingsPage. +/// На проде кнопку прячем когда товары есть; даже если нажмут — идемпотентный +/// маркер защитит от дубля. +/// +/// Безопасность: только Admin'ы и SuperAdmin (override mode). [ApiController] [Authorize(Policy = "AdminAccess")] [Route("api/admin/seed-demo")] public class DemoSeedController : ControllerBase { - private readonly DemoTenantSeeder _seeder; + private readonly DemoTenantSeeder _shortSeeder; + private readonly YearDemoSeeder _yearSeeder; private readonly ITenantContext _tenant; private readonly ILogger _log; - public DemoSeedController(DemoTenantSeeder seeder, ITenantContext tenant, ILogger log) + public DemoSeedController( + DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder, + ITenantContext tenant, ILogger log) { - _seeder = seeder; + _shortSeeder = shortSeeder; + _yearSeeder = yearSeeder; _tenant = tenant; _log = log; } /// Сводка: какие демо-сущности уже наполнены. Дешёвый — только count'ы, - /// не вызывает seed. UI использует чтобы выбрать «Заполнить» vs «Сводка». + /// не вызывает seed. UI использует чтобы выбрать «Заполнить» vs «Сводка». + /// При ?years=1 — отчёт по Year-demo (маркер Y1-). [HttpGet("status")] - public async Task> Status(CancellationToken ct) + public async Task> Status([FromQuery] int years = 0, CancellationToken ct = default) { var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant in context"); - return Ok(await _seeder.PeekAsync(orgId, ct)); + if (years >= 1) + { + return Ok(await _yearSeeder.PeekAsync(orgId, ct)); + } + return Ok(await _shortSeeder.PeekAsync(orgId, ct)); } /// Запустить seed демо-данных. Идемпотентен — если уже наполнено, - /// возвращает existing summary без вставок. + /// возвращает existing summary без вставок. При ?years=1 — Year-demo + /// (см. ): 200 товаров / 1500 продаж / год. [HttpPost] - public async Task> Run(CancellationToken ct) + public async Task> Run([FromQuery] int years = 0, CancellationToken ct = default) { var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant in context"); - _log.LogInformation("Demo seed requested for org={OrgId}", orgId); - var result = await _seeder.SeedAsync(orgId, ct); + if (years >= 1) + { + _log.LogInformation("Year-demo seed requested for org={OrgId}", orgId); + try + { + var result = await _yearSeeder.SeedAsync(orgId, ct); + _log.LogInformation("Year-demo seed done for org={OrgId}: products={Products} sales={Sales} mov={Mov}", + orgId, result.Products, result.Sales, result.StockMovements); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return Conflict(new { error = ex.Message }); + } + } + + _log.LogInformation("Demo seed (30-day) requested for org={OrgId}", orgId); + var shortResult = await _shortSeeder.SeedAsync(orgId, ct); _log.LogInformation("Demo seed done for org={OrgId}: products={Products} sales={Sales} already={Already}", - orgId, result.Products, result.Sales, result.AlreadySeeded); - return Ok(result); + orgId, shortResult.Products, shortResult.Sales, shortResult.AlreadySeeded); + return Ok(shortResult); } } diff --git a/src/food-market.api/Controllers/Dashboard/DashboardController.cs b/src/food-market.api/Controllers/Dashboard/DashboardController.cs new file mode 100644 index 0000000..e0e7632 --- /dev/null +++ b/src/food-market.api/Controllers/Dashboard/DashboardController.cs @@ -0,0 +1,170 @@ +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Dashboard; + +/// Агрегаты для виджетов главной (DashboardPage). Tenant-scoped. +/// +/// Эндпоинты не дублируют функциональность Reports/* — там более глубокая +/// аналитика с пагинацией и экспортом. Здесь — лёгкие top-N и сводки, +/// специально под верхушку дашборда. Кешируются на клиенте (TanStack Query) +/// и инвалидируются по SignalR-событию SalePosted. +[ApiController] +[Authorize] +[Route("api/dashboard")] +public class DashboardController : ControllerBase +{ + private readonly AppDbContext _db; + public DashboardController(AppDbContext db) => _db = db; + + public record TopProductRow( + Guid ProductId, string ProductName, string? ProductArticle, + decimal Revenue, decimal Quantity, int Transactions); + + /// Top-N товаров по выручке за окно последних N дней. + /// Default: 7 дней, top-5. Только проведённые чеки (Posted). + [HttpGet("top-products")] + public async Task>> TopProducts( + [FromQuery] int days = 7, [FromQuery] int limit = 5, + CancellationToken ct = default) + { + days = Math.Clamp(days, 1, 365); + limit = Math.Clamp(limit, 1, 50); + var since = DateTime.UtcNow.AddDays(-days); + + // Top by gross revenue. EF8 не умеет одновременно `Sum` + `Distinct.Count` + // в одном GroupBy → делим на два запроса: (1) агрегат по выручке/кол-ву, + // (2) сколько уникальных чеков касались этих топ-товаров. Затем join + // в памяти. + var topAgg = await ( + from l in _db.RetailSaleLines.AsNoTracking() + join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id + join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id + where s.Status == RetailSaleStatus.Posted && s.Date >= since && s.IsReturn == false + group l by new { l.ProductId, p.Name, p.Article } into g + select new + { + g.Key.ProductId, g.Key.Name, g.Key.Article, + Revenue = g.Sum(x => x.LineTotal), + Quantity = g.Sum(x => x.Quantity), + } + ).OrderByDescending(r => r.Revenue).Take(limit).ToListAsync(ct); + + var topIds = topAgg.Select(r => r.ProductId).ToList(); + var txByProduct = await ( + from l in _db.RetailSaleLines.AsNoTracking() + join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id + where s.Status == RetailSaleStatus.Posted && s.Date >= since && s.IsReturn == false + && topIds.Contains(l.ProductId) + group s by l.ProductId into g + select new { ProductId = g.Key, Count = g.Select(x => x.Id).Distinct().Count() } + ).ToListAsync(ct); + var txDict = txByProduct.ToDictionary(x => x.ProductId, x => x.Count); + var rows = topAgg.Select(r => new TopProductRow( + r.ProductId, r.Name, r.Article, r.Revenue, r.Quantity, + txDict.TryGetValue(r.ProductId, out var c) ? c : 0 + )).ToList(); + + return Ok(rows); + } + + public record LowStockRow( + Guid ProductId, string ProductName, string? ProductArticle, string? UnitName, + Guid StoreId, string StoreName, + decimal Quantity, decimal MinStock); + + /// Список товаров с остатком ≤ MinStock (Product.MinStock задан). + /// Сортировка: меньший «запас в днях» → выше; для простоты — по абсолютной + /// разности (MinStock - Quantity). + [HttpGet("low-stock")] + public async Task>> LowStock( + [FromQuery] int limit = 10, + CancellationToken ct = default) + { + limit = Math.Clamp(limit, 1, 100); + var rows = await ( + from s in _db.Stocks.AsNoTracking() + join p in _db.Products.AsNoTracking() on s.ProductId equals p.Id + join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id + join st in _db.Stores.AsNoTracking() on s.StoreId equals st.Id + where p.MinStock != null && s.Quantity <= p.MinStock + orderby (p.MinStock - s.Quantity) descending + select new LowStockRow( + p.Id, p.Name, p.Article, u.Name, + st.Id, st.Name, + s.Quantity, p.MinStock!.Value) + ).Take(limit).ToListAsync(ct); + + return Ok(rows); + } + + public record RecentSaleRow( + Guid Id, string Number, DateTime Date, + Guid StoreId, string StoreName, + decimal Total, PaymentMethod Payment, int LineCount, bool IsReturn); + + /// Последние N проведённых чеков (включая возвраты). Дашборд + /// рендерит их как live-feed: SignalR SalePosted инвалидирует + /// query, фронт перетягивает свежий список. + [HttpGet("recent-sales")] + public async Task>> RecentSales( + [FromQuery] int limit = 10, + CancellationToken ct = default) + { + limit = Math.Clamp(limit, 1, 50); + var rows = await ( + from s in _db.RetailSales.AsNoTracking() + join st in _db.Stores.AsNoTracking() on s.StoreId equals st.Id + where s.Status == RetailSaleStatus.Posted + orderby s.Date descending, s.Number descending + select new RecentSaleRow( + s.Id, s.Number, s.Date, + st.Id, st.Name, + s.Total, s.Payment, s.Lines.Count, s.IsReturn) + ).Take(limit).ToListAsync(ct); + + return Ok(rows); + } + + public record MarginSummary( + DateTime From, DateTime To, + decimal Revenue, decimal Cost, decimal Margin, decimal MarginPercent, + int Transactions); + + /// Маржа за окно N дней: выручка минус COGS (Sum(qty * UnitCost) + /// по строкам проданных товаров). Использует Product.Cost как + /// текущую себестоимость (moving-average на момент проведения не сохраняем — + /// делается отдельным отчётом /api/reports/profit, который это умеет точно). + [HttpGet("margin")] + public async Task> Margin( + [FromQuery] int days = 30, + CancellationToken ct = default) + { + days = Math.Clamp(days, 1, 365); + var since = DateTime.UtcNow.AddDays(-days); + var to = DateTime.UtcNow; + + var agg = await ( + from l in _db.RetailSaleLines.AsNoTracking() + join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id + join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id + where s.Status == RetailSaleStatus.Posted && s.Date >= since && s.IsReturn == false + group new { l, p } by 1 into g + select new + { + Revenue = g.Sum(x => x.l.LineTotal), + Cost = g.Sum(x => x.l.Quantity * x.p.Cost), + Tx = g.Select(x => x.l.RetailSaleId).Distinct().Count(), + }).FirstOrDefaultAsync(ct); + + var revenue = agg?.Revenue ?? 0m; + var cost = agg?.Cost ?? 0m; + var margin = revenue - cost; + var pct = revenue == 0m ? 0m : Math.Round(margin / revenue * 100m, 2); + + return Ok(new MarginSummary(since, to, revenue, cost, margin, pct, agg?.Tx ?? 0)); + } +} diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 7b424b9..1a918ff 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -99,7 +99,9 @@ public record SalesStatsResponse( int TransactionsToday, int TransactionsThisMonth, decimal AvgTicketThisMonth, - IReadOnlyList Series); + IReadOnlyList Series, + decimal RevenueThisWeek, + int TransactionsThisWeek); /// Aggregated sales metrics + daily series for the dashboard. /// Series buckets are days; defaults to last 30 days. @@ -113,6 +115,9 @@ public record SalesStatsResponse( var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc); var prevMonthStart = monthStart.AddMonths(-1); var seriesStart = todayStart.AddDays(-(days - 1)); + // Неделя: начиная с понедельника текущей UTC-недели. Без сложной локали — + // на дашборде это «скользящие 7 дней» в первом приближении. + var weekStart = todayStart.AddDays(-(((int)todayStart.DayOfWeek + 6) % 7)); var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted); @@ -126,6 +131,11 @@ public record SalesStatsResponse( .Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() }) .FirstOrDefaultAsync(ct); + var thisWeek = await posted.Where(s => s.Date >= weekStart) + .GroupBy(_ => 1) + .Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() }) + .FirstOrDefaultAsync(ct); + var prevMonth = await posted.Where(s => s.Date >= prevMonthStart && s.Date < monthStart) .GroupBy(_ => 1) .Select(g => new { Sum = g.Sum(s => s.Total) }) @@ -156,7 +166,9 @@ public record SalesStatsResponse( TransactionsToday: today?.Count ?? 0, TransactionsThisMonth: thisMonthCount, AvgTicketThisMonth: avgTicket, - Series: series); + Series: series, + RevenueThisWeek: thisWeek?.Sum ?? 0m, + TransactionsThisWeek: thisWeek?.Count ?? 0); } [HttpGet] diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 908ed9c..583771a 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -329,6 +329,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddHostedService(); // Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo). builder.Services.AddScoped(); + // Расширенный сидер на год операций (POST /api/admin/seed-demo?years=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/YearDemoSeeder.cs b/src/food-market.api/Seed/YearDemoSeeder.cs new file mode 100644 index 0000000..a5f479a --- /dev/null +++ b/src/food-market.api/Seed/YearDemoSeeder.cs @@ -0,0 +1,712 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Purchases; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Seed; + +/// Расширенный демо-сидер на ГОД операций для маркетинговых +/// демонстраций и e2e отчётов с богатой динамикой. +/// +/// Масштаб vs (30-day demo): 8 групп / 200 товаров +/// / 30 контрагентов / 80 приёмок / 1500 продаж с месячной сезонностью / +/// 20 возвратов / 5 инвентаризаций / 10 списаний / 3 перемещения / 8 отгрузок. +/// +/// Идемпотентен через маркер артикула Y1- — параллельно с DEMO-сидом. +/// Запрет на запуск поверх существующих активных stocks/документов: явная ошибка +/// «Tenant has activity, year-seed only on fresh tenant» (чтобы не лить хаос +/// поверх ручной работы). +/// +/// Производительность: ~30-60s на дев-vm postgres. Достигается за счёт того +/// что StockMovement'ы складываются bulk'ом, а агрегат Stocks пересчитывается +/// один раз в конце (вместо per-document SaveChanges как в short-seed'е). +/// Cost товаров фиксируется при создании ≈ 0.6 × RetailPrice (без moving-avg), +/// — для демо-отчётов P&L это «достаточно реалистично». +public sealed class YearDemoSeeder +{ + private readonly AppDbContext _db; + private readonly Random _rng = new(20260606); + + public YearDemoSeeder(AppDbContext db) => _db = db; + + public sealed record SeedSummary( + bool AlreadySeeded, + int Groups, int Products, int Counterparties, + int Supplies, int Sales, int Returns, int Demands, + int Losses, int Transfers, int Inventories, + int Stores, int StockMovements); + + public async Task SeedAsync(Guid orgId, CancellationToken ct = default) + { + var already = await _db.Products.IgnoreQueryFilters() + .AnyAsync(p => p.OrganizationId == orgId + && p.Article != null && p.Article.StartsWith("Y1-"), ct); + if (already) + { + return await CountAsync(orgId, ct); + } + + // Жёсткий гард: если tenant уже активен (есть продажи или приёмки), + // не льём поверх — это смесило бы аналитику и сломало 60%-cost эвристику. + var hasActivity = await _db.Supplies.IgnoreQueryFilters().AnyAsync(s => s.OrganizationId == orgId, ct) + || await _db.RetailSales.IgnoreQueryFilters().AnyAsync(s => s.OrganizationId == orgId, ct); + if (hasActivity) + { + throw new InvalidOperationException( + "Tenant has activity — year-seed only on fresh tenant. " + + "Удалите существующие документы или используйте короткий DEMO-сид (POST /api/admin/seed-demo без ?years=1)."); + } + + 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 stores = await _db.Stores.IgnoreQueryFilters().Where(s => s.OrganizationId == orgId).ToListAsync(ct); + var mainStore = stores.First(s => s.IsMain); + var secondStore = stores.FirstOrDefault(s => !s.IsMain); + if (secondStore is null) + { + secondStore = new Store + { + OrganizationId = orgId, Name = "Склад резерв", Code = "RESERVE", + Address = "Алматы, Тестовая 2", IsMain = false, IsActive = true, + }; + _db.Stores.Add(secondStore); + } + var retailPoint = await _db.RetailPoints.IgnoreQueryFilters() + .FirstOrDefaultAsync(rp => rp.OrganizationId == orgId, ct); + + // 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 товаров — по 25 на каждую из 8 групп. Шаблоны имени берём из массива + // ProductTemplates; цены случайные в диапазоне группы. + var products = new List(); + long bcSeq = 300_000_000_001L; + for (int g = 0; g < groups.Count; g++) + { + var (priceMin, priceMax, unit, packaging) = GroupParams(g); + var grpUnit = unit switch { "kg" => unitKg, "l" => unitL, _ => unitSht }; + for (int i = 0; i < 25; i++) + { + var name = 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 = $"Y1-{g + 1:D2}-{i + 1:D2}", + 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 = MakeEan13(bcSeq++), + Type = BarcodeType.Ean13, IsPrimary = true, + }, + }, + }; + _db.Products.Add(p); + products.Add(p); + } + } + + // 30 контрагентов: 15 поставщиков + 15 покупателей. + var counterparties = new List(); + foreach (var name in SupplierNames.Take(15)) + { + counterparties.Add(new Counterparty + { + OrganizationId = orgId, Name = name, LegalName = name, + Type = CounterpartyType.LegalEntity, + Bin = MakeBin(bcSeq++), + Phone = $"+7 (727) 1{_rng.Next(10, 99)}-{_rng.Next(10, 99)}-{_rng.Next(10, 99)}", + Notes = "Y1 supplier", + }); + } + foreach (var name in CustomerNames.Take(15)) + { + counterparties.Add(new Counterparty + { + OrganizationId = orgId, Name = name, LegalName = name, + Type = CounterpartyType.LegalEntity, + Bin = MakeBin(bcSeq++), + Phone = $"+7 (727) 2{_rng.Next(10, 99)}-{_rng.Next(10, 99)}-{_rng.Next(10, 99)}", + Notes = "Y1 customer", + }); + } + _db.Counterparties.AddRange(counterparties); + + // Сохраняем справочники, чтобы получить ID для документов. + await _db.SaveChangesAsync(ct); + + var suppliers = counterparties.Take(15).ToList(); + var customers = counterparties.Skip(15).ToList(); + + // Накопитель stock-движений: соберём всё в память, в конце AddRange и + // пересчитаем агрегат Stocks одним проходом. + var movements = new List(); + var now = DateTime.UtcNow; + var yearStart = now.AddYears(-1); + + // 80 приёмок равномерно по году. + var supplies = new List(); + for (int i = 0; i < 80; i++) + { + var date = yearStart.AddDays(365.0 * i / 80 + _rng.NextDouble() * 3); + var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(8, 16)).ToList(); + var supply = new Supply + { + OrganizationId = orgId, + Number = $"П-Y1-{i + 1:D4}", + Date = date, Status = SupplyStatus.Posted, PostedAt = date, + SupplierId = suppliers[i % suppliers.Count].Id, + StoreId = mainStore.Id, CurrencyId = currency.Id, + Notes = "Y1", + }; + decimal total = 0; + int order = 0; + foreach (var p in picked) + { + var qty = _rng.Next(30, 120); + 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); + } + + // 1500 продаж с месячной сезонностью. + // multiplier по месяцу (Jan..Dec): новогодний пик в Dec, летний спад Jul-Aug. + var monthMult = new[] { 1.0, 0.9, 1.0, 1.05, 1.1, 0.95, 0.7, 0.75, 1.0, 1.05, 1.15, 1.6 }; + // weight per day = mult[date.Month-1]; total_weight = sum across year. + var dayWeights = new double[365]; + double totalW = 0; + for (int d = 0; d < 365; d++) + { + var dt = yearStart.AddDays(d); + dayWeights[d] = monthMult[dt.Month - 1]; + totalW += dayWeights[d]; + } + // sales per day = round( 1500 * dayWeight / totalW ), distributed. + var salesPerDay = new int[365]; + var assigned = 0; + for (int d = 0; d < 365; d++) + { + salesPerDay[d] = (int)Math.Round(1500 * dayWeights[d] / totalW); + assigned += salesPerDay[d]; + } + // балансируем округление: разница =/+- 5-10 + var diff = 1500 - assigned; + while (diff != 0) + { + var d = _rng.Next(365); + if (diff > 0) { salesPerDay[d]++; diff--; } + else if (salesPerDay[d] > 0) { salesPerDay[d]--; diff++; } + } + + var sales = new List(); + int saleNo = 1; + for (int d = 0; d < 365; d++) + { + for (int s = 0; s < salesPerDay[d]; s++) + { + var date = yearStart.AddDays(d).AddHours(_rng.Next(8, 22)).AddMinutes(_rng.Next(0, 60)); + var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(1, 6)).ToList(); + var sale = new RetailSale + { + OrganizationId = orgId, + Number = $"ПР-Y1-{saleNo++:D5}", + Date = date, Status = RetailSaleStatus.Posted, PostedAt = date, + StoreId = mainStore.Id, RetailPointId = retailPoint?.Id, + CurrencyId = currency.Id, + Payment = _rng.NextDouble() < 0.55 ? 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); + } + } + + // 20 возвратов покупателей: ссылаются на случайные posted-продажи, обычно 1 строка. + var returns = new List(); + var refSales = sales.OrderBy(_ => _rng.Next()).Take(20).ToList(); + for (int i = 0; i < refSales.Count; i++) + { + var src = refSales[i]; + var srcLine = src.Lines.First(); + var qty = Math.Min(srcLine.Quantity, 1m); + var date = src.Date.AddDays(_rng.Next(1, 10)); + var ret = new RetailSale + { + OrganizationId = orgId, + Number = $"ВР-Y1-{i + 1:D3}", + Date = date, Status = RetailSaleStatus.Posted, PostedAt = date, + StoreId = src.StoreId, RetailPointId = src.RetailPointId, + CurrencyId = currency.Id, Payment = src.Payment, + IsReturn = true, ReferenceSaleId = src.Id, + }; + ret.Lines.Add(new RetailSaleLine + { + OrganizationId = orgId, ProductId = srcLine.ProductId, Quantity = qty, + UnitPrice = srcLine.UnitPrice, Discount = 0, LineTotal = qty * srcLine.UnitPrice, + VatPercent = 12, SortOrder = 0, + }); + ret.Subtotal = qty * srcLine.UnitPrice; + ret.Total = ret.Subtotal; + if (ret.Payment == PaymentMethod.Cash) ret.PaidCash = ret.Subtotal; + else ret.PaidCard = ret.Subtotal; + // CustomerReturn = stock +qty обратно + movements.Add(new StockMovement + { + OrganizationId = orgId, ProductId = srcLine.ProductId, StoreId = src.StoreId, + Quantity = qty, UnitCost = srcLine.UnitPrice, Type = MovementType.CustomerReturn, + DocumentType = "retail-sale-return", DocumentId = ret.Id, DocumentNumber = ret.Number, + OccurredAt = date, + }); + // отметим QtyReturned на исходной строке + srcLine.QtyReturned += qty; + _db.RetailSales.Add(ret); + returns.Add(ret); + } + + // 8 оптовых отгрузок (Demand). + var demands = new List(); + for (int i = 0; i < 8; i++) + { + var date = yearStart.AddDays(45.6 * i + _rng.NextDouble() * 5); + var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(5, 10)).ToList(); + var d = new Demand + { + OrganizationId = orgId, + Number = $"ОПТ-Y1-{i + 1:D3}", + Date = date, Status = DemandStatus.Posted, PostedAt = date, + CustomerId = customers[i % customers.Count].Id, StoreId = mainStore.Id, + CurrencyId = currency.Id, Payment = DemandPayment.BankTransfer, PaidAmount = 0, + Notes = "Y1", + }; + decimal total = 0; + int order = 0; + foreach (var p in picked) + { + var qty = _rng.Next(10, 30); + var price = Math.Round(p.ReferencePrice!.Value * 0.85m, 2); + var lt = qty * price; + d.Lines.Add(new DemandLine + { + OrganizationId = orgId, ProductId = p.Id, Quantity = qty, + UnitPrice = price, Discount = 0, LineTotal = lt, VatPercent = 12, SortOrder = order++, + }); + total += lt; + movements.Add(new StockMovement + { + OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id, + Quantity = -qty, UnitCost = price, Type = MovementType.WholesaleSale, + DocumentType = "demand", DocumentId = d.Id, DocumentNumber = d.Number, + OccurredAt = date, + }); + } + d.Subtotal = total; + d.Total = total; + _db.Demands.Add(d); + demands.Add(d); + } + + // 10 списаний за год (Loss): просрочка/повреждение. + var losses = new List(); + for (int i = 0; i < 10; i++) + { + var date = yearStart.AddDays(36.5 * i + _rng.NextDouble() * 5); + var p = products[_rng.Next(products.Count)]; + var qty = _rng.Next(1, 6); + var unitCost = p.Cost; + var l = new Loss + { + OrganizationId = orgId, + Number = $"С-Y1-{i + 1:D3}", + Date = date, Status = LossStatus.Posted, PostedAt = date, + StoreId = mainStore.Id, CurrencyId = currency.Id, + Reason = i % 2 == 0 ? LossReason.Expired : LossReason.Damage, + Notes = "Y1", + 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); + } + + // 3 перемещения main → second. + var transfers = new List(); + for (int i = 0; i < 3; i++) + { + var date = yearStart.AddDays(90 + 100 * i); + var p = products[_rng.Next(products.Count)]; + var qty = _rng.Next(10, 25); + var t = new Transfer + { + OrganizationId = orgId, + Number = $"ПМ-Y1-{i + 1:D3}", + Date = date, Status = TransferStatus.Posted, PostedAt = date, + FromStoreId = mainStore.Id, ToStoreId = secondStore.Id, Notes = "Y1", + }; + t.Lines.Add(new TransferLine + { + OrganizationId = orgId, TransferId = t.Id, ProductId = p.Id, + Quantity = qty, UnitCost = p.Cost, LineTotal = p.Cost * qty, SortOrder = 0, + }); + t.Total = p.Cost * qty; + movements.Add(new StockMovement + { + OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id, + Quantity = -qty, UnitCost = p.Cost, Type = MovementType.TransferOut, + DocumentType = "transfer-out", DocumentId = t.Id, DocumentNumber = t.Number, + OccurredAt = date, + }); + movements.Add(new StockMovement + { + OrganizationId = orgId, ProductId = p.Id, StoreId = secondStore.Id, + Quantity = qty, UnitCost = p.Cost, Type = MovementType.TransferIn, + DocumentType = "transfer-in", DocumentId = t.Id, DocumentNumber = t.Number, + OccurredAt = date, + }); + _db.Transfers.Add(t); + transfers.Add(t); + } + + // 5 инвентаризаций за год — на каждую берём 5-8 случайных товаров с + // ±1-2 единицей расхождения. + var inventories = new List(); + for (int i = 0; i < 5; i++) + { + var date = yearStart.AddDays(73 * i + 30); + var picked = products.OrderBy(_ => _rng.Next()).Take(_rng.Next(5, 9)).ToList(); + var inv = new InventoryDoc + { + OrganizationId = orgId, + Number = $"ИНВ-Y1-{i + 1:D3}", + Date = date, Status = InventoryStatus.Posted, PostedAt = date, + StoreId = mainStore.Id, Notes = "Y1", + }; + int order = 0; + foreach (var p in picked) + { + // book = прогноз через movements (точно не знаем без агрегации; берём random) + 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++, + }); + if (actual - book != 0) + { + movements.Add(new StockMovement + { + OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id, + Quantity = actual - book, UnitCost = p.Cost, + Type = MovementType.InventoryAdjustment, + DocumentType = "inventory", DocumentId = inv.Id, DocumentNumber = inv.Number, + OccurredAt = date, + Notes = actual > book ? "surplus" : "shortage", + }); + } + } + _db.InventoryDocs.Add(inv); + inventories.Add(inv); + } + + // Bulk-add movements. + _db.StockMovements.AddRange(movements); + + // Пересчёт агрегата Stocks: суммируем все движения по (productId, storeId). + 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: counterparties.Count, + Supplies: supplies.Count, Sales: sales.Count, Returns: returns.Count, + Demands: demands.Count, Losses: losses.Count, Transfers: transfers.Count, + Inventories: inventories.Count, + Stores: stores.Count + (secondStore is not null && !stores.Contains(secondStore) ? 1 : 0), + StockMovements: movements.Count); + } + + public async Task PeekAsync(Guid orgId, CancellationToken ct) + { + var has = await _db.Products.IgnoreQueryFilters() + .AnyAsync(p => p.OrganizationId == orgId + && p.Article != null && p.Article.StartsWith("Y1-"), 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 ret = await _db.RetailSales.IgnoreQueryFilters().CountAsync(s => s.OrganizationId == orgId && s.IsReturn, ct); + var dm = await _db.Demands.IgnoreQueryFilters().CountAsync(d => d.OrganizationId == orgId, ct); + var loss = await _db.Losses.IgnoreQueryFilters().CountAsync(l => l.OrganizationId == orgId, ct); + var tr = await _db.Transfers.IgnoreQueryFilters().CountAsync(t => t.OrganizationId == orgId, ct); + var inv = await _db.InventoryDocs.IgnoreQueryFilters().CountAsync(i => i.OrganizationId == orgId, ct); + var st = await _db.Stores.IgnoreQueryFilters().CountAsync(s => s.OrganizationId == orgId, ct); + var mov = await _db.StockMovements.IgnoreQueryFilters().CountAsync(m => m.OrganizationId == orgId, ct); + return new SeedSummary(true, grp, prod, cp, sup, sales, ret, dm, loss, tr, inv, st, mov); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private 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), // хлеб/выпечка + 2 => (180, 1900, "l", Packaging.Liquid), // напитки + 3 => (150, 1500, "sht", Packaging.Piece), // бакалея + 4 => (90, 1500, "sht", Packaging.Piece), // снеки + 5 => (60, 800, "kg", Packaging.Weight), // овощи/фрукты + 6 => (1200, 4500, "kg", Packaging.Weight), // мясо/птица + _ => (350, 2200, "sht", Packaging.Piece), // заморозка + }; + + private static string MakeEan13(long body12) + { + var s = body12.ToString("D12"); + var sum = 0; + for (int i = 0; i < 12; i++) + { + var d = s[i] - '0'; + sum += i % 2 == 0 ? d : d * 3; + } + return s + (10 - sum % 10) % 10; + } + + private static string MakeBin(long seed) => $"1234{seed % 100000000:D8}"[..12]; + + private static readonly string[] SupplierNames = + { + "ТОО «Алматы Фуд»", "ТОО «Карагандинский молокозавод»", "ИП Бегимбаев К.Т.", + "ТОО «Хлебокомбинат Астана»", "ТОО «Шымкент Дистрибьюшн»", + "ТОО «Снеки Казахстана»", "ТОО «Пивзавод Эфес»", "ТОО «Кока-Кола Алматы Боттлерс»", + "ТОО «Райымбек-Боттлерс»", "ТОО «Усть-Каменогорский мясокомбинат»", + "ИП Тулегенов Б.С.", "ТОО «Замороженные продукты Жетiсу»", + "ТОО «Сахарный завод Аксу»", "ТОО «Овощебаза Каскелен»", + "ТОО «Кондитерская фабрика Рахат»", + }; + + private static readonly string[] CustomerNames = + { + "ТОО «Сеть кафе Бариста»", "ТОО «Школа-лицей №5»", "ТОО «Гостиница Алтын»", + "ТОО «Корпоративное питание KZ»", "ТОО «Сеть пекарен Самал»", + "ТОО «Ресторан Алма»", "ТОО «Бизнес-центр Нурлы Тау»", + "ТОО «Санаторий Алатау»", "ТОО «Детский сад Балапан»", "ТОО «Спорт-клуб World Class»", + "ТОО «Сеть фастфуда Тары-Бары»", "ТОО «Ресторан Калачи»", + "ТОО «Производство тортов Назик»", "ТОО «Кофейня Coffee Boom»", + "ТОО «Сеть АЗС Helios»", + }; + + // 8 × 25 = 200 названий товаров. Реалистичные категории. + private static readonly string[][] ProductNames = + { + // 0: Молочные + new[] { + "Молоко 3.2% 1л", "Молоко 1.5% 1л", "Молоко 2.5% 1л", "Молоко безлактозное 1л", + "Кефир 1% 0.5л", "Кефир 2.5% 1л", "Йогурт натуральный 250г", "Йогурт клубника 200г", + "Сметана 20% 200г", "Сметана 25% 400г", "Сыр Голландский 1кг", "Сыр Российский 1кг", + "Творог 5% 200г", "Творог 9% 200г", "Творог обезжиренный 400г", + "Масло сливочное 200г", "Масло сливочное 82.5% 180г", + "Ряженка 1л", "Сливки 20% 200мл", "Сливки 33% 200мл", + "Сыр плавленый 100г", "Сыр Моцарелла 200г", "Сыр Пармезан 100г", + "Брынза 250г", "Кумыс 1л", + }, + // 1: Хлеб и выпечка + new[] { + "Хлеб ржаной 600г", "Хлеб пшеничный 500г", "Хлеб бородинский 400г", "Хлеб серый 500г", + "Багет французский 250г", "Лаваш тонкий 300г", "Тортилла 400г", "Чиабатта 350г", + "Булочка с маком 80г", "Булочка с корицей 90г", "Слойка с творогом 100г", + "Самса с мясом 200г", "Самса с картошкой 180г", "Пита 200г", + "Сухари ванильные 200г", "Сухари ржаные 200г", "Печенье овсяное 250г", + "Печенье курабье 300г", "Кекс лимонный 350г", "Пирог с яблоками 500г", + "Пирог с капустой 600г", "Манты замороженные 1кг", "Бешбармак замороженный 1кг", + "Тесто слоёное 500г", "Пельмени домашние 800г", + }, + // 2: Напитки + new[] { + "Вода негазированная 1.5л", "Вода газированная 1.5л", "Вода негазированная 5л", + "Кола 0.5л", "Кола 1.5л", "Кола Zero 1.5л", + "Сок яблочный 1л", "Сок апельсиновый 1л", "Сок персиковый 1л", "Сок виноградный 1л", + "Сок томатный 1л", "Морс клюквенный 1л", "Морс брусничный 1л", + "Чай чёрный пакетированный 25шт", "Чай зелёный 100г", "Чай чёрный листовой 200г", + "Кофе растворимый 100г", "Кофе зерновой 250г", "Кофе молотый 250г", + "Энергетик 0.5л", "Квас 1.5л", "Лимонад Дюшес 1.5л", "Лимонад Тархун 1.5л", + "Пиво Эфес 0.5л", "Пиво Балтика 7 0.5л", + }, + // 3: Бакалея + new[] { + "Рис круглозерный 1кг", "Рис длиннозерный 1кг", "Рис басмати 1кг", + "Гречка ядрица 800г", "Гречка зелёная 800г", + "Овсянка геркулес 500г", "Овсянка быстрая 400г", + "Пшено 800г", "Перловка 800г", "Манка 800г", + "Макароны спагетти 500г", "Макароны рожки 500г", "Макароны перья 500г", + "Сахар-песок 1кг", "Сахар тростниковый 500г", + "Соль поваренная 1кг", "Соль морская 500г", + "Мука пшеничная 2кг", "Мука ржаная 1кг", "Мука кукурузная 500г", + "Масло подсолнечное 1л", "Масло оливковое 0.5л", + "Уксус 9% 500мл", "Уксус яблочный 500мл", + "Сода пищевая 500г", + }, + // 4: Снеки + new[] { + "Чипсы Lay's 150г", "Чипсы Pringles 165г", "Чипсы Cheetos 90г", + "Сухарики Воронцовские 60г", "Сухарики Кириешки 60г", + "Орешки солёные 100г", "Орешки кешью 200г", "Миндаль жареный 150г", + "Фисташки солёные 100г", "Арахис солёный 150г", + "Семечки жареные 100г", "Семечки очищенные 200г", + "Шоколад молочный 90г", "Шоколад горький 100г", "Шоколад белый 90г", + "Батончик Snickers 50г", "Батончик Mars 50г", "Батончик Bounty 55г", + "Батончик Twix 55г", "Батончик Kit Kat 45г", + "Конфеты ассорти 200г", "Мармелад 200г", "Зефир в шоколаде 200г", + "Жевательная резинка Orbit", "Леденцы фруктовые 50г", + }, + // 5: Овощи и фрукты + new[] { + "Картофель 1кг", "Картофель молодой 1кг", "Морковь 1кг", "Свекла 1кг", + "Лук репчатый 1кг", "Лук зелёный 100г", "Чеснок 100г", + "Капуста белокочанная 1кг", "Капуста цветная 500г", "Капуста брокколи 500г", + "Огурцы 1кг", "Помидоры 1кг", "Помидоры черри 250г", + "Перец болгарский 500г", "Баклажаны 1кг", "Кабачки 1кг", + "Яблоки 1кг", "Груши 1кг", "Бананы 1кг", "Апельсины 1кг", "Мандарины 1кг", + "Лимоны 500г", "Виноград 1кг", "Клубника 500г", "Авокадо 1шт", + }, + // 6: Мясо и птица + new[] { + "Говядина вырезка 1кг", "Говядина грудинка 1кг", "Говядина фарш 500г", + "Баранина задняя часть 1кг", "Баранина рёбра 1кг", "Баранина фарш 500г", + "Конина 1кг", "Конина казы 500г", + "Свинина шея 1кг", "Свинина окорок 1кг", + "Курица целая 1.5кг", "Курица грудка 500г", "Курица бёдра 1кг", + "Курица крылья 1кг", "Куриный фарш 500г", + "Индейка грудка 1кг", "Индейка фарш 500г", + "Утка 2кг", "Гусь 3кг", + "Колбаса докторская 400г", "Колбаса сервелат 300г", "Колбаса салями 250г", + "Сосиски молочные 500г", "Сардельки говяжьи 500г", + "Шашлык маринованный 1кг", + }, + // 7: Замороженные продукты + new[] { + "Пельмени Сибирские 800г", "Пельмени Домашние 1кг", + "Манты 800г", "Вареники с картошкой 800г", "Вареники с творогом 500г", + "Хинкали 1кг", "Котлеты домашние 500г", "Котлеты куриные 500г", + "Наггетсы куриные 350г", "Стрипсы куриные 400г", + "Рыба минтай филе 800г", "Рыба хек филе 800г", + "Лосось стейки 400г", "Креветки 500г", "Кальмар очищенный 500г", + "Овощи замороженные ассорти 400г", "Брокколи замороженная 400г", + "Цветная капуста замороженная 400г", "Зелёный горошек 400г", + "Фасоль стручковая 400г", + "Мороженое пломбир 500г", "Мороженое крем-брюле 500г", + "Мороженое шоколадное 500г", "Эскимо ваниль 80г", "Рожок шоколадный 90г", + }, + }; +} diff --git a/src/food-market.web/src/components/DashboardWidgets.tsx b/src/food-market.web/src/components/DashboardWidgets.tsx new file mode 100644 index 0000000..05d9968 --- /dev/null +++ b/src/food-market.web/src/components/DashboardWidgets.tsx @@ -0,0 +1,268 @@ +/** + * Виджеты для DashboardPage: top-products, low-stock, recent-sales, margin. + * + * Все 4 виджета используют TanStack Query (одна точка кеша) и SignalR-инвалидацию + * через тот же hub, который ловит SalePosted/SupplyPosted/LowStock в DashboardPage. + * Здесь они лишь рендерятся; инвалидация сидит в родителе (qc.invalidateQueries). + * + * Skeleton-плейсхолдеры показываются на первом запросе. Empty-state — когда + * orgе ещё не наполнили данными (или фильтры/период не дали результата). + */ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { Trophy, AlertTriangle, ShoppingCart, TrendingUp, ArrowDownRight, ArrowUpRight, Banknote, CreditCard, Undo2 } from 'lucide-react' +import { Skeleton } from '@/components/Skeleton' +import { api } from '@/lib/api' +import type { + TopProductRow, LowStockRow, RecentSaleRow, MarginSummary, +} from '@/lib/types' + +const fmtMoney = new Intl.NumberFormat('ru', { maximumFractionDigits: 0 }) +const fmtQty = new Intl.NumberFormat('ru', { maximumFractionDigits: 2 }) + +function WidgetCard({ title, hint, icon: Icon, children, footer }: { + title: string + hint?: string + icon: React.ComponentType<{ className?: string }> + children: React.ReactNode + footer?: React.ReactNode +}) { + return ( +
+
+
+

+ + {title} +

+ {hint &&

{hint}

} +
+
+
{children}
+ {footer &&
{footer}
} +
+ ) +} + +// ── Top-5 товаров ────────────────────────────────────────────────────────── + +export function TopProductsWidget({ days = 7 }: { days?: number }) { + const { t } = useTranslation() + const q = useQuery({ + queryKey: ['/api/dashboard/top-products', days], + queryFn: async () => (await api.get(`/api/dashboard/top-products?days=${days}&limit=5`)).data, + }) + + return ( + + {q.isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ ) : !q.data?.length ? ( +
+ {t('dashboard.topProducts.empty', { defaultValue: 'Нет продаж за выбранный период' })} +
+ ) : ( +
    + {q.data.map((r, i) => ( +
  1. + {i + 1} + + {r.productName} + + + {fmtMoney.format(r.revenue)} ₸ + +
  2. + ))} +
+ )} +
+ ) +} + +// ── Low-stock alerts ──────────────────────────────────────────────────────── + +export function LowStockWidget({ limit = 10 }: { limit?: number }) { + const { t } = useTranslation() + const q = useQuery({ + queryKey: ['/api/dashboard/low-stock', limit], + queryFn: async () => (await api.get(`/api/dashboard/low-stock?limit=${limit}`)).data, + }) + + return ( + + {t('dashboard.lowStock.viewAll', { defaultValue: 'Все остатки →' })} + + )} + > + {q.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ ) : !q.data?.length ? ( +
+ {t('dashboard.lowStock.empty', { defaultValue: 'Все товары выше минимума' })} +
+ ) : ( +
    + {q.data.map((r) => { + const ratio = r.minStock === 0 ? 0 : r.quantity / r.minStock + const danger = ratio < 0.5 + return ( +
  • + + + {r.productName} + + + {r.storeName} + + + {fmtQty.format(r.quantity)} / {fmtQty.format(r.minStock)} + +
  • + ) + })} +
+ )} +
+ ) +} + +// ── Последние 10 продаж (live через SignalR в родителе) ──────────────────── + +export function RecentSalesWidget({ limit = 10 }: { limit?: number }) { + const { t } = useTranslation() + const q = useQuery({ + queryKey: ['/api/dashboard/recent-sales', limit], + queryFn: async () => (await api.get(`/api/dashboard/recent-sales?limit=${limit}`)).data, + refetchOnWindowFocus: true, + }) + + return ( + + {t('dashboard.recentSales.viewAll', { defaultValue: 'Все продажи →' })} + + )} + > + {q.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => )} +
+ ) : !q.data?.length ? ( +
+ {t('dashboard.recentSales.empty', { defaultValue: 'Ещё нет проведённых чеков' })} +
+ ) : ( +
    + {q.data.map((r) => { + const dt = new Date(r.date) + const time = dt.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }) + const date = dt.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }) + const PayIcon = r.payment === 0 ? Banknote : CreditCard + return ( +
  • + + {r.number} + + + {date} {time} + + {r.isReturn && } + + + {r.isReturn ? '−' : ''}{fmtMoney.format(r.total)} ₸ + +
  • + ) + })} +
+ )} +
+ ) +} + +// ── Маржа за период ──────────────────────────────────────────────────────── + +export function MarginWidget({ days = 30 }: { days?: number }) { + const { t } = useTranslation() + const q = useQuery({ + queryKey: ['/api/dashboard/margin', days], + queryFn: async () => (await api.get(`/api/dashboard/margin?days=${days}`)).data, + }) + + return ( + + {q.isLoading ? ( +
+ + +
+ ) : !q.data ? ( +
+ {t('dashboard.margin.empty', { defaultValue: 'Нет данных за период' })} +
+ ) : ( +
+
+ {fmtMoney.format(q.data.margin)} ₸ +
+
+ {q.data.marginPercent >= 0 + ? + : } + = 0 ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}> + {q.data.marginPercent.toFixed(1)}% + + {t('dashboard.margin.subtitle', { defaultValue: 'к выручке' })} +
+
+
+
{t('dashboard.margin.revenue', { defaultValue: 'Выручка' })}
+
{fmtMoney.format(q.data.revenue)} ₸
+
+
+
{t('dashboard.margin.cost', { defaultValue: 'Себестоимость' })}
+
{fmtMoney.format(q.data.cost)} ₸
+
+
+
+ )} +
+ ) +} diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 90c9642..00c9027 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -376,6 +376,52 @@ export interface SalesStatsResponse { transactionsThisMonth: number avgTicketThisMonth: number series: SalesStatsBucket[] + revenueThisWeek: number + transactionsThisWeek: number +} + +// ── Дашборд виджеты ───────────────────────────────────────────────────────── + +export interface TopProductRow { + productId: string + productName: string + productArticle: string | null + revenue: number + quantity: number + transactions: number +} + +export interface LowStockRow { + productId: string + productName: string + productArticle: string | null + unitName: string | null + storeId: string + storeName: string + quantity: number + minStock: number +} + +export interface RecentSaleRow { + id: string + number: string + date: string + storeId: string + storeName: string + total: number + payment: PaymentMethod + lineCount: number + isReturn: boolean +} + +export interface MarginSummary { + from: string + to: string + revenue: number + cost: number + margin: number + marginPercent: number + transactions: number } export interface RetailSaleDto { diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index 904eb61..b5a943e 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { lazy, Suspense, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff } from 'lucide-react' +import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff, CalendarDays } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { SalesChart } from '@/components/SalesChart' import { Skeleton } from '@/components/Skeleton' @@ -10,6 +10,13 @@ import { toast } from '@/lib/toast' import { useNotificationsHub } from '@/lib/useNotificationsHub' import type { PagedResult, SalesStatsResponse } from '@/lib/types' +// Виджеты lazy: они тянут heavy-ish DOM (списки), но критично только KPI/график +// для first-paint. Чанки уйдут отдельным запросом, skeleton — мгновенно. +const TopProductsWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.TopProductsWidget }))) +const LowStockWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.LowStockWidget }))) +const RecentSalesWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.RecentSalesWidget }))) +const MarginWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.MarginWidget }))) + interface MeResponse { sub: string name: string @@ -105,6 +112,13 @@ export function DashboardPage() { setLiveRevenueDelta((x) => x + p.total) setLiveCountDelta((x) => x + 1) qc.invalidateQueries({ queryKey: ['/api/sales/retail/stats'] }) + // Виджеты «Топ товаров», «Последние продажи», «Маржа» зависят от продаж — + // инвалидируем все четыре дашборд-запроса (low-stock тоже, т.к. продажа + // могла столкнуть остаток ниже минимума). + qc.invalidateQueries({ queryKey: ['/api/dashboard/top-products'] }) + qc.invalidateQueries({ queryKey: ['/api/dashboard/low-stock'] }) + qc.invalidateQueries({ queryKey: ['/api/dashboard/recent-sales'] }) + qc.invalidateQueries({ queryKey: ['/api/dashboard/margin'] }) }, onSupplyPosted: (p) => { toast.info(`Приёмка ${p.number} проведена на ${fmtMoney(p.total)} ₸`, { title: 'Приёмка', duration: 4000 }) @@ -139,14 +153,20 @@ export function DashboardPage() { )} /> - {/* KPI блок продажи */} -
+ {/* KPI блок продажи: today / week / month + prev-month сравнение */} +
+ -
{/* График продаж */} @@ -189,6 +203,22 @@ export function DashboardPage() { )} + {/* Виджеты «Топ товаров», «Маржа», «Last sales», «Low stock» */} +
+ }> + + + }> + + + }> + + + }> + + +
+ {/* Каталог */}