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
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:
parent
fd4d435658
commit
1044818fbb
55
docs/sprint10-progress.md
Normal file
55
docs/sprint10-progress.md
Normal 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 показывает
|
||||
«Колбасу сервелат» лидером по году.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
170
src/food-market.api/Controllers/Dashboard/DashboardController.cs
Normal file
170
src/food-market.api/Controllers/Dashboard/DashboardController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
712
src/food-market.api/Seed/YearDemoSeeder.cs
Normal file
712
src/food-market.api/Seed/YearDemoSeeder.cs
Normal 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&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г",
|
||||
},
|
||||
};
|
||||
}
|
||||
268
src/food-market.web/src/components/DashboardWidgets.tsx
Normal file
268
src/food-market.web/src/components/DashboardWidgets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue