feat(realtime): SignalR hub /hubs/notifications per-org + dashboard live
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
P2-7 Sprint 8 пункт 1.
Backend:
- src/food-market.api/Realtime/NotificationsHub.cs — SignalR-хаб, группы
org:{orgId:N}. JWT через Authorization-хедер (стандартно) или через
query ?access_token=... (для WebSocket — браузерные не могут слать
кастомные хедеры). SuperAdmin override через ?orgOverride=<id>.
- NotificationsPublisher.cs — singleton, IHubContext-обёртка.
- Program.cs — AddSignalR + MapHub. Middleware копирует ?access_token=
в Authorization для /hubs/* до UseAuthentication.
- RetailSalesController.Post → публикует SalePosted + LowStockPayload
если после движения товара остаток < MinStock. Best-effort: notify
ошибка не валит транзакцию.
- SuppliesController.Post → SupplyPosted.
Events (camelCase в JSON):
- SalePosted { saleId, number, total, storeId, cashierName, retailPointId, postedAt }
- SupplyPosted { supplyId, number, total, supplierId, supplierName, postedAt }
- LowStock { productId, productName, storeId, storeName, quantity, minStock }
Web:
- @microsoft/signalr 10.0.0 client.
- src/lib/useNotificationsHub.ts — hook с автореконнектом, accessTokenFactory.
- DashboardPage:
• liveRevenueDelta / liveCountDelta — оптимистическое приращение
«Выручка сегодня» сразу при SalePosted (до refetch stats);
• toast.info на SupplyPosted; toast.error на LowStock;
• Wifi/WifiOff индикатор в header.
Тесты:
- SignalRNotificationsTests: A постит retail-sale → A получает SalePosted,
B (другая org) НЕ получает — multi-tenant. ✓ 1/1 локально.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
51aae4482f
commit
dd2e1e7af2
|
|
@ -9,6 +9,7 @@
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||||
|
|
||||||
<!-- EF Core 8 + PostgreSQL -->
|
<!-- EF Core 8 + PostgreSQL -->
|
||||||
|
|
|
||||||
26
docs/sprint8-progress.md
Normal file
26
docs/sprint8-progress.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Sprint 8 — real-time + Telegram-бот владельца + локализация + MinIO
|
||||||
|
|
||||||
|
Цель: добавить «живой» опыт (live-обновление виджетов через SignalR),
|
||||||
|
канал для уведомлений владельцу (Telegram-бот с ежедневной сводкой),
|
||||||
|
английский UI (i18n) и перенести uploads в MinIO/S3.
|
||||||
|
|
||||||
|
Старт: 2026-05-31. Исполнитель: Claude Opus 4.7 (автономный режим).
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- Multi-tenant обязателен — Hub-группы per-org, OwnerTelegramChatId на Organization, MinIO bucket общий но object-key `{orgId}/...`.
|
||||||
|
- Каждый пункт: dotnet build + локальные тесты + `~/deploy-stage.sh` + retest на `https://test.admin.food-market.kz`.
|
||||||
|
- НЕ трогать: global.json, прод-стек (admin.food-market.kz), POS WPF.
|
||||||
|
|
||||||
|
## Чек-лист
|
||||||
|
|
||||||
|
- [ ] **1. P2-7 SignalR real-time** — Hub `/hubs/notifications` с группами per-org. События `SalePosted` / `SupplyPosted` / `LowStock`. JWT auth на коннекте. Дашборд live-виджет «Продажи сегодня» + bell-toast low-stock.
|
||||||
|
- [ ] **2. P2-14 Telegram-бот владельца** — Hangfire `OwnerDailySummaryJob` 09:00 МСК, ежедневная сводка (выручка вчера, продажи, топ-3, low-stock). Привязка через deep-link → `Organization.OwnerTelegramChatId`. UI в OrgSettings.
|
||||||
|
- [ ] **3. P2-6a Локализация UI (en)** — react-i18next, `ru.json` + `en.json`, language switcher в header. kz TODO. На стейдже smoke — все страницы переключаются.
|
||||||
|
- [ ] **4. P2-15 MinIO/S3 для uploads** — `Minio` SDK, bucket `food-market-uploads`, авто-создание на старте, миграция existing volume. `Storage:Type=Local|Minio` с fallback на Local. Тесты + UI upload картинки.
|
||||||
|
|
||||||
|
## Журнал
|
||||||
|
|
||||||
|
### 2026-05-31 — старт
|
||||||
|
|
||||||
|
Sprint UI-deep закрыт (`docs/sprint-ui-deep-progress.md`, 59/59 ✓, 6 багов починены). Перехожу к Sprint 8 пункт 1 (SignalR).
|
||||||
|
|
@ -19,12 +19,15 @@ public class SuppliesController : ControllerBase
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IStockService _stock;
|
private readonly IStockService _stock;
|
||||||
private readonly ILogger<SuppliesController> _log;
|
private readonly ILogger<SuppliesController> _log;
|
||||||
|
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
||||||
|
|
||||||
public SuppliesController(AppDbContext db, IStockService stock, ILogger<SuppliesController> log)
|
public SuppliesController(AppDbContext db, IStockService stock, ILogger<SuppliesController> log,
|
||||||
|
foodmarket.Api.Realtime.INotificationsPublisher notify)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_stock = stock;
|
_stock = stock;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_notify = notify;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SupplyListRow(
|
public record SupplyListRow(
|
||||||
|
|
@ -390,6 +393,24 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
_log.LogInformation(
|
_log.LogInformation(
|
||||||
"Supply posted: {SupplyNumber} supplier={SupplierId} store={StoreId} lines={LinesCount} total={Total}",
|
"Supply posted: {SupplyNumber} supplier={SupplierId} store={StoreId} lines={LinesCount} total={Total}",
|
||||||
supply.Number, supply.SupplierId, supply.StoreId, supply.Lines.Count, supply.Total);
|
supply.Number, supply.SupplierId, supply.StoreId, supply.Lines.Count, supply.Total);
|
||||||
|
|
||||||
|
// SignalR-уведомление в группу org (best-effort, не валит транзакцию).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var supplierName = await _db.Counterparties.IgnoreQueryFilters()
|
||||||
|
.Where(c => c.Id == supply.SupplierId)
|
||||||
|
.Select(c => c.Name).FirstOrDefaultAsync(ct);
|
||||||
|
await _notify.PublishAsync(supply.OrganizationId,
|
||||||
|
foodmarket.Api.Realtime.NotificationEvents.SupplyPosted,
|
||||||
|
new foodmarket.Api.Realtime.SupplyPostedPayload(
|
||||||
|
supply.Id, supply.Number, supply.Total, supply.SupplierId,
|
||||||
|
supplierName, supply.PostedAt!.Value));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "SignalR notify failed for supply {SupplyId}", supply.Id);
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,15 @@ public class RetailSalesController : ControllerBase
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IStockService _stock;
|
private readonly IStockService _stock;
|
||||||
private readonly ILogger<RetailSalesController> _log;
|
private readonly ILogger<RetailSalesController> _log;
|
||||||
|
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
||||||
|
|
||||||
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log)
|
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log,
|
||||||
|
foodmarket.Api.Realtime.INotificationsPublisher notify)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_stock = stock;
|
_stock = stock;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_notify = notify;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RetailSaleListRow(
|
public record RetailSaleListRow(
|
||||||
|
|
@ -422,9 +425,65 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
_log.LogInformation(
|
_log.LogInformation(
|
||||||
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
|
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
|
||||||
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
|
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
|
||||||
|
|
||||||
|
// SignalR-уведомление в группу org. Кассирa берём из CashierId если
|
||||||
|
// есть Employee.Name по UserId, иначе из User.Email (короткая часть до @).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? cashier = null;
|
||||||
|
var meSub = User.FindFirst(OpenIddict.Abstractions.OpenIddictConstants.Claims.Subject)?.Value;
|
||||||
|
if (Guid.TryParse(meSub, out var uid))
|
||||||
|
{
|
||||||
|
cashier = await _db.Employees.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.UserId == uid && e.OrganizationId == sale.OrganizationId)
|
||||||
|
.Select(e => (e.LastName + " " + e.FirstName).Trim())
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
await _notify.PublishAsync(sale.OrganizationId,
|
||||||
|
foodmarket.Api.Realtime.NotificationEvents.SalePosted,
|
||||||
|
new foodmarket.Api.Realtime.SalePostedPayload(
|
||||||
|
sale.Id, sale.Number, sale.Total, sale.StoreId,
|
||||||
|
cashier, sale.RetailPointId, sale.PostedAt!.Value));
|
||||||
|
// LowStock: после списания, если по какому-то товару остаток ушёл
|
||||||
|
// ниже MinStock, уведомим. MinStock=null → не уведомляем.
|
||||||
|
await NotifyLowStockAfterSaleAsync(sale, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Notification — best-effort: не должна валить транзакцию (она уже
|
||||||
|
// закоммичена). Логируем и идём дальше.
|
||||||
|
_log.LogWarning(ex, "SignalR notify failed for sale {SaleId}", sale.Id);
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task NotifyLowStockAfterSaleAsync(RetailSale sale, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var productIds = sale.Lines.Select(l => l.ProductId).Distinct().ToList();
|
||||||
|
if (productIds.Count == 0) return;
|
||||||
|
var infos = await (from p in _db.Products.IgnoreQueryFilters()
|
||||||
|
join s in _db.Stocks.IgnoreQueryFilters() on p.Id equals s.ProductId
|
||||||
|
where p.OrganizationId == sale.OrganizationId
|
||||||
|
&& productIds.Contains(p.Id)
|
||||||
|
&& s.StoreId == sale.StoreId
|
||||||
|
&& p.MinStock != null
|
||||||
|
&& s.Quantity < p.MinStock
|
||||||
|
select new { p.Id, p.Name, p.MinStock, s.Quantity, s.StoreId })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (infos.Count == 0) return;
|
||||||
|
var storeName = await _db.Stores.IgnoreQueryFilters()
|
||||||
|
.Where(st => st.Id == sale.StoreId)
|
||||||
|
.Select(st => st.Name).FirstOrDefaultAsync(ct);
|
||||||
|
foreach (var i in infos)
|
||||||
|
{
|
||||||
|
await _notify.PublishAsync(sale.OrganizationId,
|
||||||
|
foodmarket.Api.Realtime.NotificationEvents.LowStock,
|
||||||
|
new foodmarket.Api.Realtime.LowStockPayload(
|
||||||
|
i.Id, i.Name, i.StoreId, storeName, i.Quantity, i.MinStock ?? 0m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")]
|
[HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")]
|
||||||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,13 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
|
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
||||||
|
|
||||||
|
// SignalR + per-org notification publisher. Hub смонтирован ниже на
|
||||||
|
// /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
|
||||||
|
// имена полей в DTO (см. NotificationsPublisher payload-records).
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
|
||||||
|
foodmarket.Api.Realtime.NotificationsPublisher>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
builder.Services.AddHostedService<DevDataSeeder>();
|
||||||
|
|
@ -323,6 +330,21 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
|
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
|
||||||
// до проверки credential'ов в БД.
|
// до проверки credential'ов в БД.
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому
|
||||||
|
// токен приходит как `?access_token=...`. До UseAuthentication перекладываем
|
||||||
|
// его в Authorization-хедер, чтобы OpenIddict валидатор отработал штатно.
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
var path = ctx.Request.Path;
|
||||||
|
if (path.StartsWithSegments("/hubs")
|
||||||
|
&& !ctx.Request.Headers.ContainsKey("Authorization")
|
||||||
|
&& ctx.Request.Query.TryGetValue("access_token", out var tok)
|
||||||
|
&& !string.IsNullOrEmpty(tok))
|
||||||
|
{
|
||||||
|
ctx.Request.Headers["Authorization"] = $"Bearer {tok}";
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
// После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
|
// После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
|
||||||
|
|
@ -357,6 +379,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
// SignalR hub для live-уведомлений per-org. Path: /hubs/notifications.
|
||||||
|
// JWT доставляется либо через Authorization-заголовок (наш AuthClient
|
||||||
|
// делает это автоматически), либо через query `?access_token=...`
|
||||||
|
// (фронт react useNotificationsHub использует именно его, потому что
|
||||||
|
// браузерные WebSocket не поддерживают custom headers).
|
||||||
|
app.MapHub<foodmarket.Api.Realtime.NotificationsHub>("/hubs/notifications");
|
||||||
|
|
||||||
// /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером
|
// /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером
|
||||||
// (rate=15s типично). Доступ без авторизации — стандартная практика;
|
// (rate=15s типично). Доступ без авторизации — стандартная практика;
|
||||||
|
|
|
||||||
61
src/food-market.api/Realtime/NotificationsHub.cs
Normal file
61
src/food-market.api/Realtime/NotificationsHub.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using OpenIddict.Abstractions;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Realtime;
|
||||||
|
|
||||||
|
/// <summary>SignalR-хаб для live-уведомлений per-org. Группы именуются по
|
||||||
|
/// `OrganizationId` из JWT (claim <c>org_id</c>); при коннекте клиент
|
||||||
|
/// автоматически добавляется в свою группу, при дисконнекте — удаляется.
|
||||||
|
///
|
||||||
|
/// SuperAdmin с активным «Открыть как…» override'ом подписывается на группу
|
||||||
|
/// override-organization (так же как и обычный admin), чтобы видеть live-
|
||||||
|
/// события целевой org.
|
||||||
|
///
|
||||||
|
/// События отправляются методом <see cref="NotificationsPublisher"/>
|
||||||
|
/// (см. soседний файл). На клиенте имя метода = ключ в SignalR'е, типы
|
||||||
|
/// передаются JSON'ом — клиент описывает их сам.</summary>
|
||||||
|
[Authorize]
|
||||||
|
public sealed class NotificationsHub : Hub
|
||||||
|
{
|
||||||
|
public override Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
var orgId = ResolveOrgId(Context);
|
||||||
|
if (orgId is not null)
|
||||||
|
{
|
||||||
|
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(orgId.Value));
|
||||||
|
}
|
||||||
|
// Без org-id — соединение бесполезно (клиент не получит ничего); но
|
||||||
|
// выкидывать не будем, может это setup-wizard у SuperAdmin'a.
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
var orgId = ResolveOrgId(Context);
|
||||||
|
if (orgId is not null)
|
||||||
|
{
|
||||||
|
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(orgId.Value));
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GroupName(Guid orgId) => $"org:{orgId:N}";
|
||||||
|
|
||||||
|
private static Guid? ResolveOrgId(HubCallerContext ctx)
|
||||||
|
{
|
||||||
|
// X-Org-Override (если SuperAdmin с активной маскировкой). Hub не
|
||||||
|
// обрабатывает HTTP-заголовки автоматически — берём их из контекста.
|
||||||
|
var http = ctx.GetHttpContext();
|
||||||
|
if (http is not null && http.Request.Query.TryGetValue("orgOverride", out var qOverride)
|
||||||
|
&& Guid.TryParse(qOverride, out var ov))
|
||||||
|
{
|
||||||
|
return ov;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стандартный путь: org_id claim из JWT (см. AuthorizationController).
|
||||||
|
var raw = ctx.User?.FindFirst("org_id")?.Value
|
||||||
|
?? ctx.User?.FindFirst(OpenIddictConstants.Claims.Private.HostProperties)?.Value;
|
||||||
|
return Guid.TryParse(raw, out var g) ? g : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/food-market.api/Realtime/NotificationsPublisher.cs
Normal file
63
src/food-market.api/Realtime/NotificationsPublisher.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Realtime;
|
||||||
|
|
||||||
|
/// <summary>Тонкий обёрточный сервис: контроллеры/сервисы вызывают
|
||||||
|
/// <see cref="PublishAsync(Guid, string, object)"/>, а доставка идёт через
|
||||||
|
/// <see cref="NotificationsHub"/> в группу <c>org:{Id}</c>.
|
||||||
|
///
|
||||||
|
/// Метод JSON-сериализуется в SignalR'е автоматически (System.Text.Json).
|
||||||
|
/// Имя метода = ключ для подписки на клиенте.</summary>
|
||||||
|
public interface INotificationsPublisher
|
||||||
|
{
|
||||||
|
/// <summary>Шлём всем подключённым сессиям одной org-и событие.</summary>
|
||||||
|
Task PublishAsync(Guid orgId, string method, object payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NotificationsPublisher : INotificationsPublisher
|
||||||
|
{
|
||||||
|
private readonly IHubContext<NotificationsHub> _hub;
|
||||||
|
public NotificationsPublisher(IHubContext<NotificationsHub> hub) => _hub = hub;
|
||||||
|
|
||||||
|
public Task PublishAsync(Guid orgId, string method, object payload)
|
||||||
|
=> _hub.Clients.Group(NotificationsHub.GroupName(orgId)).SendAsync(method, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DTO событий — стабильные имена для контракта с фронтом.</summary>
|
||||||
|
public static class NotificationEvents
|
||||||
|
{
|
||||||
|
/// <summary>Сразу после успешного post-a розничной продажи.</summary>
|
||||||
|
public const string SalePosted = "SalePosted";
|
||||||
|
/// <summary>Сразу после успешного post-a приёмки.</summary>
|
||||||
|
public const string SupplyPosted = "SupplyPosted";
|
||||||
|
/// <summary>Когда после движения остаток продукта стал ниже MinStock.
|
||||||
|
/// Один event на каждый продукт-склад, в котором факт пересечения
|
||||||
|
/// порога. Шлётся только когда было «выше» и стало «ниже» — иначе на
|
||||||
|
/// каждой продаже сыпались бы тосты.</summary>
|
||||||
|
public const string LowStock = "LowStock";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SalePostedPayload(
|
||||||
|
Guid SaleId,
|
||||||
|
string Number,
|
||||||
|
decimal Total,
|
||||||
|
Guid StoreId,
|
||||||
|
string? CashierName,
|
||||||
|
Guid? RetailPointId,
|
||||||
|
DateTime PostedAt);
|
||||||
|
|
||||||
|
public sealed record SupplyPostedPayload(
|
||||||
|
Guid SupplyId,
|
||||||
|
string Number,
|
||||||
|
decimal Total,
|
||||||
|
Guid SupplierId,
|
||||||
|
string? SupplierName,
|
||||||
|
DateTime PostedAt);
|
||||||
|
|
||||||
|
public sealed record LowStockPayload(
|
||||||
|
Guid ProductId,
|
||||||
|
string ProductName,
|
||||||
|
Guid StoreId,
|
||||||
|
string? StoreName,
|
||||||
|
decimal Quantity,
|
||||||
|
decimal MinStock);
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.99.2",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
"@tanstack/react-query-devtools": "^5.99.2",
|
"@tanstack/react-query-devtools": "^5.99.2",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ importers:
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.73.1(react@19.2.5))
|
version: 5.2.2(react-hook-form@7.73.1(react@19.2.5))
|
||||||
|
'@microsoft/signalr':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.99.2
|
specifier: ^5.99.2
|
||||||
version: 5.99.2(react@19.2.5)
|
version: 5.99.2(react@19.2.5)
|
||||||
|
|
@ -290,6 +293,9 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@microsoft/signalr@10.0.0':
|
||||||
|
resolution: {integrity: sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.4':
|
'@napi-rs/wasm-runtime@1.1.4':
|
||||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -660,6 +666,10 @@ packages:
|
||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
|
engines: {node: '>=6.5'}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -949,9 +959,17 @@ packages:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1:
|
||||||
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
eventemitter3@5.0.4:
|
eventemitter3@5.0.4:
|
||||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
|
eventsource@2.0.2:
|
||||||
|
resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
|
@ -970,6 +988,9 @@ packages:
|
||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fetch-cookie@2.2.0:
|
||||||
|
resolution: {integrity: sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
@ -1277,6 +1298,15 @@ packages:
|
||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
|
engines: {node: 4.x || >=6.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
encoding: ^0.1.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
encoding:
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-releases@2.0.37:
|
node-releases@2.0.37:
|
||||||
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
|
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
|
||||||
|
|
||||||
|
|
@ -1340,10 +1370,16 @@ packages:
|
||||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
psl@1.15.0:
|
||||||
|
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
querystringify@2.2.0:
|
||||||
|
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
||||||
|
|
||||||
react-datepicker@9.1.0:
|
react-datepicker@9.1.0:
|
||||||
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
|
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1421,6 +1457,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
requires-port@1.0.0:
|
||||||
|
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||||
|
|
||||||
reselect@5.1.1:
|
reselect@5.1.1:
|
||||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
|
|
@ -1497,6 +1536,13 @@ packages:
|
||||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tough-cookie@4.1.4:
|
||||||
|
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
ts-api-utils@2.5.0:
|
ts-api-utils@2.5.0:
|
||||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
|
|
@ -1529,6 +1575,10 @@ packages:
|
||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
|
universalify@0.2.0:
|
||||||
|
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
|
||||||
|
engines: {node: '>= 4.0.0'}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3:
|
update-browserslist-db@1.2.3:
|
||||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -1541,6 +1591,9 @@ packages:
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
url-parse@1.5.10:
|
||||||
|
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
||||||
|
|
||||||
use-sync-external-store@1.6.0:
|
use-sync-external-store@1.6.0:
|
||||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1592,6 +1645,12 @@ packages:
|
||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1:
|
||||||
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -1601,6 +1660,18 @@ packages:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
ws@7.5.11:
|
||||||
|
resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==}
|
||||||
|
engines: {node: '>=8.3.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: ^5.0.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
|
@ -1853,6 +1924,18 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@microsoft/signalr@10.0.0':
|
||||||
|
dependencies:
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
eventsource: 2.0.2
|
||||||
|
fetch-cookie: 2.2.0
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
ws: 7.5.11
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- encoding
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
|
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.9.2
|
'@emnapi/core': 1.9.2
|
||||||
|
|
@ -2180,6 +2263,10 @@ snapshots:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||||
vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1)
|
vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1)
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
event-target-shim: 5.0.1
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
|
|
@ -2470,8 +2557,12 @@ snapshots:
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
eventemitter3@5.0.4: {}
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
|
eventsource@2.0.2: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
|
|
@ -2482,6 +2573,11 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
fetch-cookie@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
tough-cookie: 4.1.4
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
|
|
@ -2723,6 +2819,10 @@ snapshots:
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
node-releases@2.0.37: {}
|
node-releases@2.0.37: {}
|
||||||
|
|
||||||
openapi-typescript@7.13.0(typescript@6.0.3):
|
openapi-typescript@7.13.0(typescript@6.0.3):
|
||||||
|
|
@ -2784,8 +2884,14 @@ snapshots:
|
||||||
|
|
||||||
proxy-from-env@2.1.0: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
|
psl@1.15.0:
|
||||||
|
dependencies:
|
||||||
|
punycode: 2.3.1
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
querystringify@2.2.0: {}
|
||||||
|
|
||||||
react-datepicker@9.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
react-datepicker@9.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
|
@ -2858,6 +2964,8 @@ snapshots:
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
requires-port@1.0.0: {}
|
||||||
|
|
||||||
reselect@5.1.1: {}
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
@ -2926,6 +3034,15 @@ snapshots:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tough-cookie@4.1.4:
|
||||||
|
dependencies:
|
||||||
|
psl: 1.15.0
|
||||||
|
punycode: 2.3.1
|
||||||
|
universalify: 0.2.0
|
||||||
|
url-parse: 1.5.10
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
@ -2954,6 +3071,8 @@ snapshots:
|
||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
|
universalify@0.2.0: {}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.2
|
browserslist: 4.28.2
|
||||||
|
|
@ -2966,6 +3085,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
url-parse@1.5.10:
|
||||||
|
dependencies:
|
||||||
|
querystringify: 2.2.0
|
||||||
|
requires-port: 1.0.0
|
||||||
|
|
||||||
use-sync-external-store@1.6.0(react@19.2.5):
|
use-sync-external-store@1.6.0(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|
@ -2999,12 +3123,21 @@ snapshots:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
tr46: 0.0.3
|
||||||
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
ws@7.5.11: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yaml-ast-parser@0.0.43: {}
|
yaml-ast-parser@0.0.43: {}
|
||||||
|
|
|
||||||
99
src/food-market.web/src/lib/useNotificationsHub.ts
Normal file
99
src/food-market.web/src/lib/useNotificationsHub.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr'
|
||||||
|
import { getAccessToken } from '@/lib/auth'
|
||||||
|
import { getOrgOverride } from '@/lib/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge между фронтом и API SignalR-хабом `/hubs/notifications`.
|
||||||
|
* Подписка живёт на всё время монтирования компонента (обычно AppLayout).
|
||||||
|
*
|
||||||
|
* События:
|
||||||
|
* - SalePosted → { saleId, number, total, storeId, cashierName, retailPointId, postedAt }
|
||||||
|
* - SupplyPosted → { supplyId, number, total, supplierId, supplierName, postedAt }
|
||||||
|
* - LowStock → { productId, productName, storeId, storeName, quantity, minStock }
|
||||||
|
*
|
||||||
|
* Поля приходят camelCase (System.Text.Json по умолчанию пишет именно так,
|
||||||
|
* хотя record-DTO именованы PascalCase — это нормально).
|
||||||
|
*
|
||||||
|
* Auto-reconnect через built-in withAutomaticReconnect — после отключения
|
||||||
|
* (например задержка сети) клиент сам пересоединится. Только в случае
|
||||||
|
* сменa access_token нужно ручное reconnect (мы делаем это через ключ
|
||||||
|
* tokenChanged — в App.tsx, если будет нужно).
|
||||||
|
*
|
||||||
|
* Если SignalR не доступен (например на стейдже не задеплоен) — компонент
|
||||||
|
* не падает, просто isConnected=false.
|
||||||
|
*/
|
||||||
|
export interface SalePostedPayload {
|
||||||
|
saleId: string
|
||||||
|
number: string
|
||||||
|
total: number
|
||||||
|
storeId: string
|
||||||
|
cashierName: string | null
|
||||||
|
retailPointId: string | null
|
||||||
|
postedAt: string
|
||||||
|
}
|
||||||
|
export interface SupplyPostedPayload {
|
||||||
|
supplyId: string
|
||||||
|
number: string
|
||||||
|
total: number
|
||||||
|
supplierId: string
|
||||||
|
supplierName: string | null
|
||||||
|
postedAt: string
|
||||||
|
}
|
||||||
|
export interface LowStockPayload {
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
storeId: string
|
||||||
|
storeName: string | null
|
||||||
|
quantity: number
|
||||||
|
minStock: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationsHandlers {
|
||||||
|
onSalePosted?: (p: SalePostedPayload) => void
|
||||||
|
onSupplyPosted?: (p: SupplyPostedPayload) => void
|
||||||
|
onLowStock?: (p: LowStockPayload) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationsHub(handlers: NotificationsHandlers) {
|
||||||
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (!token) return
|
||||||
|
const override = getOrgOverride()
|
||||||
|
const url = new URL('/hubs/notifications', window.location.origin)
|
||||||
|
if (override?.id) url.searchParams.set('orgOverride', override.id)
|
||||||
|
// access_token передаём через accessTokenFactory (билдер сам подставит
|
||||||
|
// его в query при WebSocket-handshake'е).
|
||||||
|
let cancelled = false
|
||||||
|
const conn: HubConnection = new HubConnectionBuilder()
|
||||||
|
.withUrl(url.toString(), { accessTokenFactory: () => getAccessToken() ?? '' })
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.configureLogging(LogLevel.Warning)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
if (handlers.onSalePosted) conn.on('SalePosted', handlers.onSalePosted)
|
||||||
|
if (handlers.onSupplyPosted) conn.on('SupplyPosted', handlers.onSupplyPosted)
|
||||||
|
if (handlers.onLowStock) conn.on('LowStock', handlers.onLowStock)
|
||||||
|
conn.onreconnected(() => setIsConnected(true))
|
||||||
|
conn.onreconnecting(() => setIsConnected(false))
|
||||||
|
conn.onclose(() => setIsConnected(false))
|
||||||
|
|
||||||
|
conn.start()
|
||||||
|
.then(() => { if (!cancelled) setIsConnected(conn.state === HubConnectionState.Connected) })
|
||||||
|
.catch((e) => {
|
||||||
|
// Не падаем. На фронте отсутствие live-обновлений = деградация, не баг.
|
||||||
|
console.warn('[SignalR] connect failed:', e instanceof Error ? e.message : e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
conn.stop().catch(() => {})
|
||||||
|
}
|
||||||
|
// handlers стабильны — React.memo / useCallback на стороне caller'а,
|
||||||
|
// не пересоздаём connection на каждый рендер
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { isConnected }
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { SalesChart } from '@/components/SalesChart'
|
import { SalesChart } from '@/components/SalesChart'
|
||||||
import { Skeleton } from '@/components/Skeleton'
|
import { Skeleton } from '@/components/Skeleton'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { useNotificationsHub } from '@/lib/useNotificationsHub'
|
||||||
import type { PagedResult, SalesStatsResponse } from '@/lib/types'
|
import type { PagedResult, SalesStatsResponse } from '@/lib/types'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
|
|
@ -75,6 +78,7 @@ function MiniCard({ icon: Icon, label, value, isLoading }: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
const me = useQuery({
|
const me = useQuery({
|
||||||
queryKey: ['me'],
|
queryKey: ['me'],
|
||||||
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
||||||
|
|
@ -88,6 +92,30 @@ export function DashboardPage() {
|
||||||
const stores = useCount('/api/catalog/stores')
|
const stores = useCount('/api/catalog/stores')
|
||||||
const retailPoints = useCount('/api/catalog/retail-points')
|
const retailPoints = useCount('/api/catalog/retail-points')
|
||||||
|
|
||||||
|
// Live-обновление через SignalR. SalePosted сразу инвалидирует stats query
|
||||||
|
// (виджет «Выручка сегодня» подтянет свежее значение). LowStock — bell-toast
|
||||||
|
// с именем продукта. liveRevenue — оптимистичное приращение к UI до того,
|
||||||
|
// как stats refetch'нётся (на гладкость глаза).
|
||||||
|
const [liveRevenueDelta, setLiveRevenueDelta] = useState(0)
|
||||||
|
const [liveCountDelta, setLiveCountDelta] = useState(0)
|
||||||
|
const { isConnected } = useNotificationsHub({
|
||||||
|
onSalePosted: (p) => {
|
||||||
|
setLiveRevenueDelta((x) => x + p.total)
|
||||||
|
setLiveCountDelta((x) => x + 1)
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/sales/retail/stats'] })
|
||||||
|
},
|
||||||
|
onSupplyPosted: (p) => {
|
||||||
|
toast.info(`Приёмка ${p.number} проведена на ${fmtMoney(p.total)} ₸`, { title: 'Приёмка', duration: 4000 })
|
||||||
|
},
|
||||||
|
onLowStock: (p) => {
|
||||||
|
const store = p.storeName ? ` на «${p.storeName}»` : ''
|
||||||
|
toast.error(
|
||||||
|
`«${p.productName}»${store}: остаток ${p.quantity} ≤ минимум ${p.minStock}`,
|
||||||
|
{ title: 'Низкий остаток', duration: 8000 },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const monthDelta = stats.data && stats.data.revenuePrevMonth > 0
|
const monthDelta = stats.data && stats.data.revenuePrevMonth > 0
|
||||||
? ((stats.data.revenueThisMonth - stats.data.revenuePrevMonth) / stats.data.revenuePrevMonth) * 100
|
? ((stats.data.revenueThisMonth - stats.data.revenuePrevMonth) / stats.data.revenuePrevMonth) * 100
|
||||||
: null
|
: null
|
||||||
|
|
@ -99,6 +127,16 @@ export function DashboardPage() {
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
|
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
|
||||||
|
actions={(
|
||||||
|
// Индикатор live-связи: SignalR connected = зелёный Wifi, off = серый.
|
||||||
|
// Title с подсказкой, чтобы не было непонятной иконки.
|
||||||
|
<span title={isConnected ? 'Live-обновления включены' : 'Live-обновления отключены'} className="inline-flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
{isConnected
|
||||||
|
? <Wifi className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
: <WifiOff className="w-3.5 h-3.5 text-slate-400" />}
|
||||||
|
{isConnected ? 'live' : 'offline'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* KPI блок продажи */}
|
{/* KPI блок продажи */}
|
||||||
|
|
@ -106,8 +144,8 @@ export function DashboardPage() {
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={Banknote}
|
icon={Banknote}
|
||||||
label="Выручка сегодня"
|
label="Выручка сегодня"
|
||||||
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueToday ?? 0)} ₸`}
|
value={stats.isLoading ? '…' : `${fmtMoney((stats.data?.revenueToday ?? 0) + liveRevenueDelta)} ₸`}
|
||||||
hint={`${stats.data?.transactionsToday ?? 0} чеков`}
|
hint={`${(stats.data?.transactionsToday ?? 0) + liveCountDelta} чеков`}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
|
|
|
||||||
156
tests/food-market.IntegrationTests/SignalRNotificationsTests.cs
Normal file
156
tests/food-market.IntegrationTests/SignalRNotificationsTests.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>SignalR-хаб per-org. После Post-a розничной продажи владелец org
|
||||||
|
/// получает событие <c>SalePosted</c>, владелец другой org — НЕТ (multi-tenant).
|
||||||
|
/// Тест поднимает реальный API через WebApplicationFactory, использует TestServer
|
||||||
|
/// в качестве handler'a SignalR-клиента.</summary>
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class SignalRNotificationsTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public SignalRNotificationsTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sale_posted_emits_event_to_own_org_only()
|
||||||
|
{
|
||||||
|
var actorA = new ApiActor(_factory.CreateClient());
|
||||||
|
var emailA = $"sigA-{Guid.NewGuid():N}@example.kz";
|
||||||
|
(await actorA.SignupAsync(emailA, "Passw0rd!", "SigA Org")).EnsureSuccessStatusCode();
|
||||||
|
var tokenA = await actorA.TokenAsync(emailA, "Passw0rd!");
|
||||||
|
actorA.UseToken(tokenA);
|
||||||
|
|
||||||
|
var actorB = new ApiActor(_factory.CreateClient());
|
||||||
|
var emailB = $"sigB-{Guid.NewGuid():N}@example.kz";
|
||||||
|
(await actorB.SignupAsync(emailB, "Passw0rd!", "SigB Org")).EnsureSuccessStatusCode();
|
||||||
|
var tokenB = await actorB.TokenAsync(emailB, "Passw0rd!");
|
||||||
|
actorB.UseToken(tokenB);
|
||||||
|
|
||||||
|
// Поднимаем хаб для обеих org через WebApplicationFactory-handler.
|
||||||
|
var hubA = await BuildHubAsync(tokenA);
|
||||||
|
var hubB = await BuildHubAsync(tokenB);
|
||||||
|
|
||||||
|
var aGotSale = new TaskCompletionSource<System.Text.Json.JsonElement>();
|
||||||
|
var bGotSale = new TaskCompletionSource<System.Text.Json.JsonElement>();
|
||||||
|
hubA.On<System.Text.Json.JsonElement>("SalePosted", p => aGotSale.TrySetResult(p));
|
||||||
|
hubB.On<System.Text.Json.JsonElement>("SalePosted", p => bGotSale.TrySetResult(p));
|
||||||
|
|
||||||
|
// Засеем у A продукт + остаток через приёмку.
|
||||||
|
var (productId, storeId) = await SeedProductWithStockAsync(actorA);
|
||||||
|
|
||||||
|
// A создаёт чек и проводит. Currency / retail-point — те же что уже есть в bootstrap'е.
|
||||||
|
var currencyId = (await actorA.GetJsonAsync("/api/catalog/currencies"))
|
||||||
|
.GetProperty("items").EnumerateArray()
|
||||||
|
.First(x => x.GetProperty("code").GetString() == "KZT")
|
||||||
|
.GetProperty("id").GetString();
|
||||||
|
var retailPointId = (await actorA.GetJsonAsync("/api/catalog/retail-points"))
|
||||||
|
.GetProperty("items").EnumerateArray().First()
|
||||||
|
.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
var saleResp = await actorA.Http.PostAsJsonAsync("/api/sales/retail", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow,
|
||||||
|
storeId = storeId,
|
||||||
|
retailPointId = retailPointId,
|
||||||
|
currencyId = currencyId,
|
||||||
|
payment = 0,
|
||||||
|
isReturn = false,
|
||||||
|
lines = new[] { new { productId = productId, quantity = 1, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
|
||||||
|
subtotal = 100m,
|
||||||
|
discountTotal = 0m,
|
||||||
|
total = 100m,
|
||||||
|
paidCash = 100m,
|
||||||
|
paidCard = 0m,
|
||||||
|
});
|
||||||
|
saleResp.EnsureSuccessStatusCode();
|
||||||
|
var sale = (await saleResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
|
||||||
|
var saleId = sale.GetProperty("id").GetString();
|
||||||
|
var postResp = await actorA.Http.PostAsync($"/api/sales/retail/{saleId}/post", null);
|
||||||
|
postResp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// Ждём событие у A (timeout 8с — Hub queue + SignalR handshake).
|
||||||
|
var aEvt = await Task.WhenAny(aGotSale.Task, Task.Delay(TimeSpan.FromSeconds(8)));
|
||||||
|
aEvt.Should().Be(aGotSale.Task, "A должен был получить SalePosted");
|
||||||
|
var payload = await aGotSale.Task;
|
||||||
|
// Поля camelCase (System.Text.Json default).
|
||||||
|
payload.GetProperty("number").GetString().Should().NotBeNullOrEmpty();
|
||||||
|
payload.GetProperty("total").GetDecimal().Should().Be(100m);
|
||||||
|
|
||||||
|
// B не должен получить ничего за то же время.
|
||||||
|
var bEvt = await Task.WhenAny(bGotSale.Task, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
bEvt.Should().NotBe(bGotSale.Task, "B НЕ должен получать события другой org (multi-tenant leak)");
|
||||||
|
|
||||||
|
await hubA.StopAsync();
|
||||||
|
await hubB.StopAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HubConnection> BuildHubAsync(string token)
|
||||||
|
{
|
||||||
|
var conn = new HubConnectionBuilder()
|
||||||
|
.WithUrl("http://localhost/hubs/notifications", o =>
|
||||||
|
{
|
||||||
|
o.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler();
|
||||||
|
o.AccessTokenProvider = () => Task.FromResult<string?>(token);
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
await conn.StartAsync();
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string productId, string storeId)> SeedProductWithStockAsync(ApiActor actor)
|
||||||
|
{
|
||||||
|
var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200"))
|
||||||
|
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796");
|
||||||
|
var groups = (await actor.GetJsonAsync("/api/catalog/product-groups"))
|
||||||
|
.GetProperty("items").EnumerateArray().First();
|
||||||
|
var priceTypes = (await actor.GetJsonAsync("/api/catalog/price-types"))
|
||||||
|
.GetProperty("items").EnumerateArray()
|
||||||
|
.First(x => x.GetProperty("isRetail").GetBoolean());
|
||||||
|
var currencies = (await actor.GetJsonAsync("/api/catalog/currencies"))
|
||||||
|
.GetProperty("items").EnumerateArray()
|
||||||
|
.First(x => x.GetProperty("code").GetString() == "KZT");
|
||||||
|
var stores = (await actor.GetJsonAsync("/api/catalog/stores"))
|
||||||
|
.GetProperty("items").EnumerateArray()
|
||||||
|
.First(x => x.GetProperty("isMain").GetBoolean());
|
||||||
|
var storeId = stores.GetProperty("id").GetString()!;
|
||||||
|
|
||||||
|
var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new
|
||||||
|
{
|
||||||
|
name = "SignalR test", article = $"SR-{Guid.NewGuid():N}",
|
||||||
|
unitOfMeasureId = units.GetProperty("id").GetString(),
|
||||||
|
vat = 12, vatEnabled = true,
|
||||||
|
productGroupId = groups.GetProperty("id").GetString(),
|
||||||
|
packaging = 1,
|
||||||
|
prices = new[] { new { priceTypeId = priceTypes.GetProperty("id").GetString(), amount = 100m, currencyId = currencies.GetProperty("id").GetString() } },
|
||||||
|
barcodes = new[] { new { code = "5000000000010", type = 1, isPrimary = true } },
|
||||||
|
});
|
||||||
|
prodResp.EnsureSuccessStatusCode();
|
||||||
|
var prod = (await prodResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
|
||||||
|
var productId = prod.GetProperty("id").GetString()!;
|
||||||
|
|
||||||
|
var supplierResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties", new { name = "Supp SR", type = 2 });
|
||||||
|
supplierResp.EnsureSuccessStatusCode();
|
||||||
|
var supplier = (await supplierResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
|
||||||
|
var supplierId = supplier.GetProperty("id").GetString()!;
|
||||||
|
|
||||||
|
var supplyResp = await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow,
|
||||||
|
supplierId = supplierId,
|
||||||
|
storeId = storeId,
|
||||||
|
currencyId = currencies.GetProperty("id").GetString(),
|
||||||
|
lines = new[] { new { productId = productId, quantity = 10m, unitPrice = 50m } },
|
||||||
|
});
|
||||||
|
supplyResp.EnsureSuccessStatusCode();
|
||||||
|
var supply = (await supplyResp.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>())!;
|
||||||
|
var supplyId = supply.GetProperty("id").GetString()!;
|
||||||
|
(await actor.Http.PostAsync($"/api/purchases/supplies/{supplyId}/post", null)).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return (productId, storeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
<PackageReference Include="FluentAssertions" />
|
<PackageReference Include="FluentAssertions" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
||||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue