diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1d1cf4e..89a09a2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,7 @@
+
diff --git a/docs/sprint8-progress.md b/docs/sprint8-progress.md
new file mode 100644
index 0000000..9eeff5e
--- /dev/null
+++ b/docs/sprint8-progress.md
@@ -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).
diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
index c2eff39..8b2e111 100644
--- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs
+++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
@@ -19,12 +19,15 @@ public class SuppliesController : ControllerBase
private readonly AppDbContext _db;
private readonly IStockService _stock;
private readonly ILogger _log;
+ private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
- public SuppliesController(AppDbContext db, IStockService stock, ILogger log)
+ public SuppliesController(AppDbContext db, IStockService stock, ILogger log,
+ foodmarket.Api.Realtime.INotificationsPublisher notify)
{
_db = db;
_stock = stock;
_log = log;
+ _notify = notify;
}
public record SupplyListRow(
@@ -390,6 +393,24 @@ public async Task 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();
}
diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
index b26c6e2..6341d88 100644
--- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs
+++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
@@ -19,12 +19,15 @@ public class RetailSalesController : ControllerBase
private readonly AppDbContext _db;
private readonly IStockService _stock;
private readonly ILogger _log;
+ private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
- public RetailSalesController(AppDbContext db, IStockService stock, ILogger log)
+ public RetailSalesController(AppDbContext db, IStockService stock, ILogger log,
+ foodmarket.Api.Realtime.INotificationsPublisher notify)
{
_db = db;
_stock = stock;
_log = log;
+ _notify = notify;
}
public record RetailSaleListRow(
@@ -422,9 +425,65 @@ public async Task 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 Unpost(Guid id, CancellationToken ct)
{
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 89f1b5a..001f9df 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -299,6 +299,13 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddScoped();
builder.Services.AddScoped();
+ // SignalR + per-org notification publisher. Hub смонтирован ниже на
+ // /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase
+ // имена полей в DTO (см. NotificationsPublisher payload-records).
+ builder.Services.AddSignalR();
+ builder.Services.AddSingleton();
+
builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
@@ -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("/hubs/notifications");
// /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером
// (rate=15s типично). Доступ без авторизации — стандартная практика;
diff --git a/src/food-market.api/Realtime/NotificationsHub.cs b/src/food-market.api/Realtime/NotificationsHub.cs
new file mode 100644
index 0000000..5fba887
--- /dev/null
+++ b/src/food-market.api/Realtime/NotificationsHub.cs
@@ -0,0 +1,61 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+using OpenIddict.Abstractions;
+
+namespace foodmarket.Api.Realtime;
+
+/// SignalR-хаб для live-уведомлений per-org. Группы именуются по
+/// `OrganizationId` из JWT (claim org_id); при коннекте клиент
+/// автоматически добавляется в свою группу, при дисконнекте — удаляется.
+///
+/// SuperAdmin с активным «Открыть как…» override'ом подписывается на группу
+/// override-organization (так же как и обычный admin), чтобы видеть live-
+/// события целевой org.
+///
+/// События отправляются методом
+/// (см. soседний файл). На клиенте имя метода = ключ в SignalR'е, типы
+/// передаются JSON'ом — клиент описывает их сам.
+[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;
+ }
+}
diff --git a/src/food-market.api/Realtime/NotificationsPublisher.cs b/src/food-market.api/Realtime/NotificationsPublisher.cs
new file mode 100644
index 0000000..b333b75
--- /dev/null
+++ b/src/food-market.api/Realtime/NotificationsPublisher.cs
@@ -0,0 +1,63 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace foodmarket.Api.Realtime;
+
+/// Тонкий обёрточный сервис: контроллеры/сервисы вызывают
+/// , а доставка идёт через
+/// в группу org:{Id}.
+///
+/// Метод JSON-сериализуется в SignalR'е автоматически (System.Text.Json).
+/// Имя метода = ключ для подписки на клиенте.
+public interface INotificationsPublisher
+{
+ /// Шлём всем подключённым сессиям одной org-и событие.
+ Task PublishAsync(Guid orgId, string method, object payload);
+}
+
+public sealed class NotificationsPublisher : INotificationsPublisher
+{
+ private readonly IHubContext _hub;
+ public NotificationsPublisher(IHubContext hub) => _hub = hub;
+
+ public Task PublishAsync(Guid orgId, string method, object payload)
+ => _hub.Clients.Group(NotificationsHub.GroupName(orgId)).SendAsync(method, payload);
+}
+
+/// DTO событий — стабильные имена для контракта с фронтом.
+public static class NotificationEvents
+{
+ /// Сразу после успешного post-a розничной продажи.
+ public const string SalePosted = "SalePosted";
+ /// Сразу после успешного post-a приёмки.
+ public const string SupplyPosted = "SupplyPosted";
+ /// Когда после движения остаток продукта стал ниже MinStock.
+ /// Один event на каждый продукт-склад, в котором факт пересечения
+ /// порога. Шлётся только когда было «выше» и стало «ниже» — иначе на
+ /// каждой продаже сыпались бы тосты.
+ 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);
diff --git a/src/food-market.web/package.json b/src/food-market.web/package.json
index b2ee08a..d313a48 100644
--- a/src/food-market.web/package.json
+++ b/src/food-market.web/package.json
@@ -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",
diff --git a/src/food-market.web/pnpm-lock.yaml b/src/food-market.web/pnpm-lock.yaml
index 23d1c39..b144015 100644
--- a/src/food-market.web/pnpm-lock.yaml
+++ b/src/food-market.web/pnpm-lock.yaml
@@ -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: {}
diff --git a/src/food-market.web/src/lib/useNotificationsHub.ts b/src/food-market.web/src/lib/useNotificationsHub.ts
new file mode 100644
index 0000000..9dadd59
--- /dev/null
+++ b/src/food-market.web/src/lib/useNotificationsHub.ts
@@ -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 }
+}
diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx
index b07159d..db6caf3 100644
--- a/src/food-market.web/src/pages/DashboardPage.tsx
+++ b/src/food-market.web/src/pages/DashboardPage.tsx
@@ -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('/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() {
+ {isConnected
+ ?
+ : }
+ {isConnected ? 'live' : 'offline'}
+
+ )}
/>
{/* KPI блок продажи */}
@@ -106,8 +144,8 @@ export function DashboardPage() {
SignalR-хаб per-org. После Post-a розничной продажи владелец org
+/// получает событие SalePosted, владелец другой org — НЕТ (multi-tenant).
+/// Тест поднимает реальный API через WebApplicationFactory, использует TestServer
+/// в качестве handler'a SignalR-клиента.
+[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();
+ var bGotSale = new TaskCompletionSource();
+ hubA.On("SalePosted", p => aGotSale.TrySetResult(p));
+ hubB.On("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())!;
+ 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 BuildHubAsync(string token)
+ {
+ var conn = new HubConnectionBuilder()
+ .WithUrl("http://localhost/hubs/notifications", o =>
+ {
+ o.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler();
+ o.AccessTokenProvider = () => Task.FromResult(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())!;
+ 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())!;
+ 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())!;
+ var supplyId = supply.GetProperty("id").GetString()!;
+ (await actor.Http.PostAsync($"/api/purchases/supplies/{supplyId}/post", null)).EnsureSuccessStatusCode();
+
+ return (productId, storeId);
+ }
+}
diff --git a/tests/food-market.IntegrationTests/food-market.IntegrationTests.csproj b/tests/food-market.IntegrationTests/food-market.IntegrationTests.csproj
index d900bc7..aa99941 100644
--- a/tests/food-market.IntegrationTests/food-market.IntegrationTests.csproj
+++ b/tests/food-market.IntegrationTests/food-market.IntegrationTests.csproj
@@ -20,6 +20,7 @@
+