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

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:
nns 2026-05-31 19:29:59 +05:00
parent 51aae4482f
commit dd2e1e7af2
13 changed files with 693 additions and 6 deletions

View file

@ -9,6 +9,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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.Client" Version="8.0.11" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<!-- EF Core 8 + PostgreSQL -->

26
docs/sprint8-progress.md Normal file
View 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).

View file

@ -19,12 +19,15 @@ public class SuppliesController : ControllerBase
private readonly AppDbContext _db;
private readonly IStockService _stock;
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;
_stock = stock;
_log = log;
_notify = notify;
}
public record SupplyListRow(
@ -390,6 +393,24 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
_log.LogInformation(
"Supply posted: {SupplyNumber} supplier={SupplierId} store={StoreId} lines={LinesCount} total={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();
}

View file

@ -19,12 +19,15 @@ public class RetailSalesController : ControllerBase
private readonly AppDbContext _db;
private readonly IStockService _stock;
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;
_stock = stock;
_log = log;
_notify = notify;
}
public record RetailSaleListRow(
@ -422,9 +425,65 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
_log.LogInformation(
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={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();
}
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")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{

View file

@ -299,6 +299,13 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
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<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();
@ -323,6 +330,21 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
// до проверки credential'ов в БД.
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.UseAuthorization();
// После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
@ -357,6 +379,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
}
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-сервером
// (rate=15s типично). Доступ без авторизации — стандартная практика;

View 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;
}
}

View 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);

View file

@ -12,6 +12,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@microsoft/signalr": "^10.0.0",
"@tanstack/react-query": "^5.99.2",
"@tanstack/react-query-devtools": "^5.99.2",
"axios": "^1.15.1",

View file

@ -11,6 +11,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
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':
specifier: ^5.99.2
version: 5.99.2(react@19.2.5)
@ -290,6 +293,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
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':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@ -660,6 +666,10 @@ packages:
babel-plugin-react-compiler:
optional: true
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -949,9 +959,17 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
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:
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:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -970,6 +988,9 @@ packages:
picomatch:
optional: true
fetch-cookie@2.2.0:
resolution: {integrity: sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@ -1277,6 +1298,15 @@ packages:
natural-compare@1.4.0:
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:
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
@ -1340,10 +1370,16 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
psl@1.15.0:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
react-datepicker@9.1.0:
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
peerDependencies:
@ -1421,6 +1457,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@ -1497,6 +1536,13 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
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:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@ -1529,6 +1575,10 @@ packages:
undici-types@7.16.0:
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:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@ -1541,6 +1591,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@ -1592,6 +1645,12 @@ packages:
yaml:
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:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -1601,6 +1660,18 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
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:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -1853,6 +1924,18 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@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)':
dependencies:
'@emnapi/core': 1.9.2
@ -2180,6 +2263,10 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.7
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):
dependencies:
acorn: 8.16.0
@ -2470,8 +2557,12 @@ snapshots:
esutils@2.0.3: {}
event-target-shim@5.0.1: {}
eventemitter3@5.0.4: {}
eventsource@2.0.2: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@ -2482,6 +2573,11 @@ snapshots:
optionalDependencies:
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:
dependencies:
flat-cache: 4.0.1
@ -2723,6 +2819,10 @@ snapshots:
natural-compare@1.4.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-releases@2.0.37: {}
openapi-typescript@7.13.0(typescript@6.0.3):
@ -2784,8 +2884,14 @@ snapshots:
proxy-from-env@2.1.0: {}
psl@1.15.0:
dependencies:
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):
dependencies:
'@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: {}
requires-port@1.0.0: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
@ -2926,6 +3034,15 @@ snapshots:
fdir: 6.5.0(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):
dependencies:
typescript: 6.0.3
@ -2954,6 +3071,8 @@ snapshots:
undici-types@7.16.0: {}
universalify@0.2.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@ -2966,6 +3085,11 @@ snapshots:
dependencies:
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):
dependencies:
react: 19.2.5
@ -2999,12 +3123,21 @@ snapshots:
fsevents: 2.3.3
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:
dependencies:
isexe: 2.0.0
word-wrap@1.2.5: {}
ws@7.5.11: {}
yallist@3.1.1: {}
yaml-ast-parser@0.0.43: {}

View 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 }
}

View file

@ -1,9 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
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 { SalesChart } from '@/components/SalesChart'
import { Skeleton } from '@/components/Skeleton'
import { api } from '@/lib/api'
import { toast } from '@/lib/toast'
import { useNotificationsHub } from '@/lib/useNotificationsHub'
import type { PagedResult, SalesStatsResponse } from '@/lib/types'
interface MeResponse {
@ -75,6 +78,7 @@ function MiniCard({ icon: Icon, label, value, isLoading }: {
}
export function DashboardPage() {
const qc = useQueryClient()
const me = useQuery({
queryKey: ['me'],
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
@ -88,6 +92,30 @@ export function DashboardPage() {
const stores = useCount('/api/catalog/stores')
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
? ((stats.data.revenueThisMonth - stats.data.revenuePrevMonth) / stats.data.revenuePrevMonth) * 100
: null
@ -99,6 +127,16 @@ export function DashboardPage() {
<PageHeader
title="Dashboard"
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 блок продажи */}
@ -106,8 +144,8 @@ export function DashboardPage() {
<KpiCard
icon={Banknote}
label="Выручка сегодня"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueToday ?? 0)}`}
hint={`${stats.data?.transactionsToday ?? 0} чеков`}
value={stats.isLoading ? '…' : `${fmtMoney((stats.data?.revenueToday ?? 0) + liveRevenueDelta)}`}
hint={`${(stats.data?.transactionsToday ?? 0) + liveCountDelta} чеков`}
/>
<KpiCard
icon={Calendar}

View 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);
}
}

View file

@ -20,6 +20,7 @@
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>