From dd2e1e7af217c923f500e765d724f83219e83aeb Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 31 May 2026 19:29:59 +0500 Subject: [PATCH] feat(realtime): SignalR hub /hubs/notifications per-org + dashboard live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=. - 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 --- Directory.Packages.props | 1 + docs/sprint8-progress.md | 26 +++ .../Purchases/SuppliesController.cs | 23 ++- .../Sales/RetailSalesController.cs | 61 ++++++- src/food-market.api/Program.cs | 28 ++++ .../Realtime/NotificationsHub.cs | 61 +++++++ .../Realtime/NotificationsPublisher.cs | 63 +++++++ src/food-market.web/package.json | 1 + src/food-market.web/pnpm-lock.yaml | 133 +++++++++++++++ .../src/lib/useNotificationsHub.ts | 99 +++++++++++ .../src/pages/DashboardPage.tsx | 46 +++++- .../SignalRNotificationsTests.cs | 156 ++++++++++++++++++ .../food-market.IntegrationTests.csproj | 1 + 13 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 docs/sprint8-progress.md create mode 100644 src/food-market.api/Realtime/NotificationsHub.cs create mode 100644 src/food-market.api/Realtime/NotificationsPublisher.cs create mode 100644 src/food-market.web/src/lib/useNotificationsHub.ts create mode 100644 tests/food-market.IntegrationTests/SignalRNotificationsTests.cs 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 @@ +