feat(s10): year-demo seeder + 4 dashboard виджета + week-stats
Some checks are pending
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
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-06 01:03:36 +05:00
parent fd4d435658
commit 1044818fbb
9 changed files with 1363 additions and 31 deletions

55
docs/sprint10-progress.md Normal file
View file

@ -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 показывает
«Колбасу сервелат» лидером по году.

View file

@ -6,52 +6,89 @@
namespace foodmarket.Api.Controllers.Admin;
/// <summary>Заполнение тестового стейджа реалистичными демо-данными по запросу
/// admin'a текущего tenant'а. См. <see cref="DemoTenantSeeder"/> — там логика
/// (5 групп / 50 товаров / 10 контрагентов / документы).
/// admin'a текущего tenant'а.
///
/// Использование: с UI кнопка «Заполнить демо-данными» в OrganizationSettingsPage
/// (только когда счётчик товаров = 0 в идемпотентной проверке).
/// Два режима:
/// <list type="bullet">
/// <item><b>Short demo</b> (POST /api/admin/seed-demo без параметра) —
/// <see cref="DemoTenantSeeder"/>: 5 групп / 50 товаров / 10 контрагентов /
/// 5 приёмок / 30 продаж за последние 30 дней. Маркер <c>DEMO-</c>.</item>
/// <item><b>Year demo</b> (POST /api/admin/seed-demo?years=1) —
/// <see cref="YearDemoSeeder"/>: 8 групп / 200 товаров / 30 контрагентов /
/// 80 приёмок / 1500 продаж с сезонностью (Dec peak, Jul-Aug spada) /
/// 20 возвратов / 5 инвентаризаций / 10 списаний / 3 перемещения /
/// 8 отгрузок. Маркер <c>Y1-</c>. Доступен только на свежем tenant'е
/// (без активных Supply/RetailSale).</item>
/// </list>
///
/// Безопасность: только Admin'ы и SuperAdmin (override mode). Не использовать
/// на проде (UI кнопку прячем когда товары уже есть; но даже если нажмут —
/// идемпотентный маркер DEMO- защитит от дубля).</summary>
/// Использование: UI кнопка «Заполнить демо-данными» в OrganizationSettingsPage.
/// На проде кнопку прячем когда товары есть; даже если нажмут — идемпотентный
/// маркер защитит от дубля.
///
/// Безопасность: только Admin'ы и SuperAdmin (override mode).</summary>
[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<DemoSeedController> _log;
public DemoSeedController(DemoTenantSeeder seeder, ITenantContext tenant, ILogger<DemoSeedController> log)
public DemoSeedController(
DemoTenantSeeder shortSeeder, YearDemoSeeder yearSeeder,
ITenantContext tenant, ILogger<DemoSeedController> log)
{
_seeder = seeder;
_shortSeeder = shortSeeder;
_yearSeeder = yearSeeder;
_tenant = tenant;
_log = log;
}
/// <summary>Сводка: какие демо-сущности уже наполнены. Дешёвый — только count'ы,
/// не вызывает seed. UI использует чтобы выбрать «Заполнить» vs «Сводка».</summary>
/// не вызывает seed. UI использует чтобы выбрать «Заполнить» vs «Сводка».
/// При <c>?years=1</c> — отчёт по Year-demo (маркер Y1-).</summary>
[HttpGet("status")]
public async Task<ActionResult<DemoTenantSeeder.SeedSummary>> Status(CancellationToken ct)
public async Task<ActionResult<object>> 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));
}
/// <summary>Запустить seed демо-данных. Идемпотентен — если уже наполнено,
/// возвращает existing summary без вставок.</summary>
/// возвращает existing summary без вставок. При <c>?years=1</c> — Year-demo
/// (см. <see cref="YearDemoSeeder"/>): 200 товаров / 1500 продаж / год.</summary>
[HttpPost]
public async Task<ActionResult<DemoTenantSeeder.SeedSummary>> Run(CancellationToken ct)
public async Task<ActionResult<object>> 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);
_log.LogInformation("Demo seed done for org={OrgId}: products={Products} sales={Sales} already={Already}",
orgId, result.Products, result.Sales, result.AlreadySeeded);
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, shortResult.Products, shortResult.Sales, shortResult.AlreadySeeded);
return Ok(shortResult);
}
}

View file

@ -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;
/// <summary>Агрегаты для виджетов главной (DashboardPage). Tenant-scoped.
///
/// Эндпоинты не дублируют функциональность Reports/* — там более глубокая
/// аналитика с пагинацией и экспортом. Здесь — лёгкие top-N и сводки,
/// специально под верхушку дашборда. Кешируются на клиенте (TanStack Query)
/// и инвалидируются по SignalR-событию <c>SalePosted</c>.</summary>
[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);
/// <summary>Top-N товаров по выручке за окно последних N дней.
/// Default: 7 дней, top-5. Только проведённые чеки (Posted).</summary>
[HttpGet("top-products")]
public async Task<ActionResult<IReadOnlyList<TopProductRow>>> 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);
/// <summary>Список товаров с остатком ≤ MinStock (Product.MinStock задан).
/// Сортировка: меньший «запас в днях» → выше; для простоты — по абсолютной
/// разности (MinStock - Quantity).</summary>
[HttpGet("low-stock")]
public async Task<ActionResult<IReadOnlyList<LowStockRow>>> 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);
/// <summary>Последние N проведённых чеков (включая возвраты). Дашборд
/// рендерит их как live-feed: SignalR <c>SalePosted</c> инвалидирует
/// query, фронт перетягивает свежий список.</summary>
[HttpGet("recent-sales")]
public async Task<ActionResult<IReadOnlyList<RecentSaleRow>>> 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);
/// <summary>Маржа за окно N дней: выручка минус COGS (Sum(qty * UnitCost)
/// по строкам проданных товаров). Использует <c>Product.Cost</c> как
/// текущую себестоимость (moving-average на момент проведения не сохраняем —
/// делается отдельным отчётом /api/reports/profit, который это умеет точно).</summary>
[HttpGet("margin")]
public async Task<ActionResult<MarginSummary>> 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));
}
}

View file

@ -99,7 +99,9 @@ public record SalesStatsResponse(
int TransactionsToday,
int TransactionsThisMonth,
decimal AvgTicketThisMonth,
IReadOnlyList<SalesStatsBucket> Series);
IReadOnlyList<SalesStatsBucket> Series,
decimal RevenueThisWeek,
int TransactionsThisWeek);
/// <summary>Aggregated sales metrics + daily series for the dashboard.
/// Series buckets are days; defaults to last 30 days.</summary>
@ -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]

View file

@ -329,6 +329,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddHostedService<DevDataSeeder>();
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
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,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;
/// <summary>Расширенный демо-сидер на ГОД операций для маркетинговых
/// демонстраций и e2e отчётов с богатой динамикой.
///
/// Масштаб vs <see cref="DemoTenantSeeder"/> (30-day demo): 8 групп / 200 товаров
/// / 30 контрагентов / 80 приёмок / 1500 продаж с месячной сезонностью /
/// 20 возвратов / 5 инвентаризаций / 10 списаний / 3 перемещения / 8 отгрузок.
///
/// Идемпотентен через маркер артикула <c>Y1-</c> — параллельно с 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&amp;L это «достаточно реалистично».</summary>
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<SeedSummary> 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<Product>();
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<ProductPrice>
{
new() {
OrganizationId = orgId, PriceTypeId = retailPrice.Id,
Amount = price, CurrencyId = currency.Id,
},
},
Barcodes = new List<ProductBarcode>
{
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<Counterparty>();
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<StockMovement>();
var now = DateTime.UtcNow;
var yearStart = now.AddYears(-1);
// 80 приёмок равномерно по году.
var supplies = new List<Supply>();
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<RetailSale>();
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<RetailSale>();
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<Demand>();
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<Loss>();
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<Transfer>();
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<InventoryDoc>();
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<SeedSummary> 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<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 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г",
},
};
}

View file

@ -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 (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Icon className="w-4 h-4 text-slate-500" />
{title}
</h2>
{hint && <p className="text-xs text-slate-500 mt-0.5">{hint}</p>}
</div>
</div>
<div>{children}</div>
{footer && <div className="mt-3 pt-2 border-t border-slate-100 dark:border-slate-800">{footer}</div>}
</section>
)
}
// ── 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<TopProductRow[]>(`/api/dashboard/top-products?days=${days}&limit=5`)).data,
})
return (
<WidgetCard
title={t('dashboard.topProducts.title', { defaultValue: 'Топ-5 товаров по выручке' })}
hint={t('dashboard.topProducts.hint', { defaultValue: `за последние ${days} дн.` })}
icon={Trophy}
>
{q.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
</div>
) : !q.data?.length ? (
<div className="text-sm text-slate-400 py-6 text-center">
{t('dashboard.topProducts.empty', { defaultValue: 'Нет продаж за выбранный период' })}
</div>
) : (
<ol className="space-y-1.5">
{q.data.map((r, i) => (
<li key={r.productId} className="flex items-center gap-3 py-1.5 px-1 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded">
<span className={`w-6 text-center text-xs font-semibold rounded ${
i === 0 ? 'text-amber-600 dark:text-amber-400'
: i === 1 ? 'text-slate-500 dark:text-slate-300'
: i === 2 ? 'text-orange-700 dark:text-orange-400'
: 'text-slate-400'
}`}>{i + 1}</span>
<Link
to={`/catalog/products/${r.productId}`}
className="flex-1 min-w-0 text-sm text-slate-900 dark:text-slate-100 truncate hover:text-emerald-700 dark:hover:text-emerald-400"
>
{r.productName}
</Link>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 tabular-nums whitespace-nowrap">
{fmtMoney.format(r.revenue)}
</span>
</li>
))}
</ol>
)}
</WidgetCard>
)
}
// ── 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<LowStockRow[]>(`/api/dashboard/low-stock?limit=${limit}`)).data,
})
return (
<WidgetCard
title={t('dashboard.lowStock.title', { defaultValue: 'Low-stock alerts' })}
hint={t('dashboard.lowStock.hint', { defaultValue: 'Остаток ≤ минимума' })}
icon={AlertTriangle}
footer={(
<Link to="/inventory/stock" className="text-xs text-emerald-700 dark:text-emerald-400 hover:underline">
{t('dashboard.lowStock.viewAll', { defaultValue: 'Все остатки →' })}
</Link>
)}
>
{q.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
</div>
) : !q.data?.length ? (
<div className="text-sm text-slate-400 py-6 text-center">
{t('dashboard.lowStock.empty', { defaultValue: 'Все товары выше минимума' })}
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{q.data.map((r) => {
const ratio = r.minStock === 0 ? 0 : r.quantity / r.minStock
const danger = ratio < 0.5
return (
<li key={`${r.productId}_${r.storeId}`} className="py-2 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${danger ? 'bg-red-500' : 'bg-amber-500'}`} />
<Link
to={`/catalog/products/${r.productId}`}
className="flex-1 min-w-0 text-sm text-slate-900 dark:text-slate-100 truncate hover:text-emerald-700 dark:hover:text-emerald-400"
>
{r.productName}
</Link>
<span className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[120px]" title={r.storeName}>
{r.storeName}
</span>
<span className="text-sm tabular-nums whitespace-nowrap text-slate-900 dark:text-slate-100">
{fmtQty.format(r.quantity)} / {fmtQty.format(r.minStock)}
</span>
</li>
)
})}
</ul>
)}
</WidgetCard>
)
}
// ── Последние 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<RecentSaleRow[]>(`/api/dashboard/recent-sales?limit=${limit}`)).data,
refetchOnWindowFocus: true,
})
return (
<WidgetCard
title={t('dashboard.recentSales.title', { defaultValue: 'Последние продажи' })}
hint={t('dashboard.recentSales.hint', { defaultValue: 'Live-обновление' })}
icon={ShoppingCart}
footer={(
<Link to="/sales/retail" className="text-xs text-emerald-700 dark:text-emerald-400 hover:underline">
{t('dashboard.recentSales.viewAll', { defaultValue: 'Все продажи →' })}
</Link>
)}
>
{q.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
</div>
) : !q.data?.length ? (
<div className="text-sm text-slate-400 py-6 text-center">
{t('dashboard.recentSales.empty', { defaultValue: 'Ещё нет проведённых чеков' })}
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{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 (
<li key={r.id} className="py-2 flex items-center gap-2 text-sm">
<Link
to={`/sales/retail/${r.id}`}
className="flex-1 min-w-0 truncate text-slate-900 dark:text-slate-100 hover:text-emerald-700 dark:hover:text-emerald-400"
>
{r.number}
</Link>
<span className="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap tabular-nums">
{date} {time}
</span>
{r.isReturn && <Undo2 className="w-3.5 h-3.5 text-red-500" aria-label="возврат" />}
<PayIcon className="w-3.5 h-3.5 text-slate-400" />
<span className={`tabular-nums whitespace-nowrap font-medium ${r.isReturn ? 'text-red-600 dark:text-red-400' : 'text-slate-900 dark:text-slate-100'}`}>
{r.isReturn ? '' : ''}{fmtMoney.format(r.total)}
</span>
</li>
)
})}
</ul>
)}
</WidgetCard>
)
}
// ── Маржа за период ────────────────────────────────────────────────────────
export function MarginWidget({ days = 30 }: { days?: number }) {
const { t } = useTranslation()
const q = useQuery({
queryKey: ['/api/dashboard/margin', days],
queryFn: async () => (await api.get<MarginSummary>(`/api/dashboard/margin?days=${days}`)).data,
})
return (
<WidgetCard
title={t('dashboard.margin.title', { defaultValue: 'Маржа' })}
hint={t('dashboard.margin.hint', { defaultValue: `за ${days} дн.` })}
icon={TrendingUp}
>
{q.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-48" />
</div>
) : !q.data ? (
<div className="text-sm text-slate-400 py-6 text-center">
{t('dashboard.margin.empty', { defaultValue: 'Нет данных за период' })}
</div>
) : (
<div>
<div className="text-3xl font-semibold text-slate-900 dark:text-slate-100">
{fmtMoney.format(q.data.margin)}
</div>
<div className="mt-1 text-xs text-slate-500 flex items-center gap-1.5">
{q.data.marginPercent >= 0
? <ArrowUpRight className="w-3 h-3 text-emerald-600" />
: <ArrowDownRight className="w-3 h-3 text-red-600" />}
<span className={q.data.marginPercent >= 0 ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}>
{q.data.marginPercent.toFixed(1)}%
</span>
{t('dashboard.margin.subtitle', { defaultValue: 'к выручке' })}
</div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<dt className="text-slate-500">{t('dashboard.margin.revenue', { defaultValue: 'Выручка' })}</dt>
<dd className="text-slate-900 dark:text-slate-100 tabular-nums">{fmtMoney.format(q.data.revenue)} </dd>
</div>
<div>
<dt className="text-slate-500">{t('dashboard.margin.cost', { defaultValue: 'Себестоимость' })}</dt>
<dd className="text-slate-900 dark:text-slate-100 tabular-nums">{fmtMoney.format(q.data.cost)} </dd>
</div>
</dl>
</div>
)}
</WidgetCard>
)
}

View file

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

View file

@ -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 блок продажи */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* KPI блок продажи: today / week / month + prev-month сравнение */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
icon={Banknote}
label={t('dashboard.revenueToday')}
value={stats.isLoading ? '…' : `${fmtMoney((stats.data?.revenueToday ?? 0) + liveRevenueDelta)}`}
hint={t('dashboard.receiptsCount', { count: (stats.data?.transactionsToday ?? 0) + liveCountDelta })}
/>
<KpiCard
icon={CalendarDays}
label={t('dashboard.revenueWeek', { defaultValue: 'Выручка за неделю' })}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisWeek ?? 0)}`}
hint={t('dashboard.receiptsCount', { count: stats.data?.transactionsThisWeek ?? 0 })}
/>
<KpiCard
icon={Calendar}
label={t('dashboard.revenueMonth')}
@ -160,12 +180,6 @@ export function DashboardPage() {
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)}`}
hint={t('dashboard.perMonth')}
/>
<KpiCard
icon={TrendingUp}
label={t('dashboard.prevMonth')}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenuePrevMonth ?? 0)}`}
hint={t('dashboard.forCompare')}
/>
</div>
{/* График продаж */}
@ -189,6 +203,22 @@ export function DashboardPage() {
)}
</section>
{/* Виджеты «Топ товаров», «Маржа», «Last sales», «Low stock» */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<TopProductsWidget days={7} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<MarginWidget days={30} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<RecentSalesWidget limit={10} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<LowStockWidget limit={10} />
</Suspense>
</div>
{/* Каталог */}
<div>
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">