From 29a951a6ffd10b119522cac0a8395d052c589153 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 02:56:59 +0500 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20=D1=86=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=80=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20+=20=D0=B0=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F/=D1=8D=D1=81=D0=BA=D0=B0=D0=BB=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=B8=D0=BD=D1=83=D1=81=D0=BE=D0=B2=20+=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Персистентные уведомления (таблица notifications, Phase41) + REST /api/notifications (список/счётчик/read/read-all) → центр уведомлений в вебе (колокольчик, API-backed) и мобильном API. Фоновый джоб NegativeStockNotificationJob (cron */5): агрегирует новые минусы в ОДНО уведомление на склад (дебаунс по NotifiedAt + интервал орг., по умолч. 15 мин), уважает тихие часы (Asia/Almaty); эскалация просроченных (>N часов, по умолч. 24) владельцу/админам. Получатели — админы, владелец, кладовщики (право InventoryEdit). Каналы: SignalR (живой колокольчик), Telegram (владельцу), push-заглушка. Настройки организации: NegStockNotify{Enabled,IntervalMinutes,QuietFrom/ToHour, EscalateHours} + GET/PUT /api/organization/settings + секция в настройках веба. Блок «Просроченные минусы» на дашборде. Тесты: unit тихих часов 3/3; integration — джоб создаёт уведомление админу, дебаунс, read-all 3/3 (вся группа NegativeStockTests зелёная). Co-Authored-By: Claude Opus 4.8 --- .../Background/HangfireJobsConfigurator.cs | 10 + .../NegativeStockNotificationJob.cs | 224 +++++++++++++++ .../Controllers/NotificationsController.cs | 95 +++++++ .../OrganizationSettingsController.cs | 37 ++- src/food-market.api/Program.cs | 1 + .../Realtime/NotificationsPublisher.cs | 11 + .../Notifications/Notification.cs | 45 +++ .../Organizations/Organization.cs | 22 ++ .../Persistence/AppDbContext.cs | 15 + .../20261009030000_Phase41_Notifications.cs | 64 +++++ .../src/components/NotificationCenter.tsx | 258 ++++++++---------- .../components/OverdueNegativeStockBlock.tsx | 70 +++++ .../src/lib/useNotifications.ts | 48 ++++ .../src/lib/useNotificationsHub.ts | 10 + src/food-market.web/src/lib/useOrgSettings.ts | 6 + .../src/pages/DashboardPage.tsx | 4 + .../src/pages/OrganizationSettingsPage.tsx | 42 +++ .../NegativeStockTests.cs | 55 ++++ .../NegativeStockNotifyQuietHoursTests.cs | 46 ++++ .../NoopNegativeStockAlertService.cs | 21 ++ .../StockServicePropertyTests.cs | 6 +- .../StockServiceTests.cs | 8 +- 22 files changed, 939 insertions(+), 159 deletions(-) create mode 100644 src/food-market.api/Background/NegativeStockNotificationJob.cs create mode 100644 src/food-market.api/Controllers/NotificationsController.cs create mode 100644 src/food-market.domain/Notifications/Notification.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261009030000_Phase41_Notifications.cs create mode 100644 src/food-market.web/src/components/OverdueNegativeStockBlock.tsx create mode 100644 src/food-market.web/src/lib/useNotifications.ts create mode 100644 tests/food-market.UnitTests/NegativeStockNotifyQuietHoursTests.cs create mode 100644 tests/food-market.UnitTests/NoopNegativeStockAlertService.cs diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index 26a700d..d73f996 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -143,6 +143,16 @@ public Task StartAsync(CancellationToken ct) cronExpression: cronTgDaily, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Уведомления об отрицательных остатках: агрегированная рассылка + эскалация. + // Бежит часто (каждые 5 минут), сам дебаунсит через NotifiedAt и настройку + // интервала/тихих часов организации. + var cronNegStock = _cfg["Hangfire:Cron:NegativeStockNotify"] ?? "*/5 * * * *"; + _jobs.AddOrUpdate( + recurringJobId: "negative-stock-notify", + methodCall: j => j.RunAsync(CancellationToken.None), + cronExpression: cronNegStock, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + return Task.CompletedTask; } diff --git a/src/food-market.api/Background/NegativeStockNotificationJob.cs b/src/food-market.api/Background/NegativeStockNotificationJob.cs new file mode 100644 index 0000000..74b387e --- /dev/null +++ b/src/food-market.api/Background/NegativeStockNotificationJob.cs @@ -0,0 +1,224 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using foodmarket.Api.Infrastructure.Tenancy; +using foodmarket.Api.Integrations.Telegram; +using foodmarket.Api.Realtime; +using foodmarket.Application.Notifications; +using foodmarket.Domain.Notifications; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Background; + +/// Фоновый джоб уведомлений об отрицательных остатках. Бежит часто +/// (cron каждые 5 минут), но сам дебаунсит через NegativeStockAlert.NotifiedAt: +/// +/// 1. Агрегация — по каждому складу, где появились новые минусы +/// (NotifiedAt == null) и с прошлой рассылки по складу прошло ≥ N минут +/// (настройка орг., по умолч. 15), шлём ОДНО уведомление «N товаров ушли в +/// минус» со списком. Получатели — администраторы, владелец и кладовщики +/// (роль с InventoryEdit). Тихие часы (настройка) — рассылку откладываем. +/// 2. Эскалация — минус, не закрытый за N часов (настройка, по умолч. 24) +/// и ещё не эскалированный (EscalatedAt == null), повторно шлём владельцу/ +/// админам как срочный. Эскалация тихие часы игнорирует (срочно). +/// +/// Запись уведомления персистентная (таблица notifications → центр уведомлений +/// в вебе и мобильном API). Дополнительно пушим SignalR-событие (живой +/// колокольчик), Telegram (владельцу) и push-заглушку. +public sealed class NegativeStockNotificationJob +{ + // Казахстан — единая зона Asia/Almaty = UTC+5 (с 01.03.2024 вся страна). + private const int KzUtcOffsetHours = 5; + + private readonly AppDbContext _db; + private readonly INotificationsPublisher _publisher; + private readonly ITelegramBotClient _tg; + private readonly IPushSender _push; + private readonly ILogger _log; + + public NegativeStockNotificationJob( + AppDbContext db, INotificationsPublisher publisher, ITelegramBotClient tg, + IPushSender push, ILogger log) + { + _db = db; _publisher = publisher; _tg = tg; _push = push; _log = log; + } + + public async Task RunAsync(CancellationToken ct = default) + { + var orgs = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.NegStockNotifyEnabled && !o.IsArchived && o.IsActive) + .Select(o => new OrgCfg(o.Id, o.Name, o.NegStockNotifyIntervalMinutes, + o.NegStockQuietFromHour, o.NegStockQuietToHour, o.NegStockEscalateHours, + o.OwnerTelegramChatId, o.AccountOwnerUserId)) + .ToListAsync(ct); + + foreach (var org in orgs) + { + try + { + using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false); + await ProcessOrgAsync(org, ct); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Negative-stock notify failed for org={OrgId}", org.Id); + } + } + } + + private sealed record OrgCfg(Guid Id, string Name, int IntervalMinutes, int? QuietFrom, + int? QuietTo, int EscalateHours, long? OwnerChatId, Guid? OwnerUserId); + + private async Task ProcessOrgAsync(OrgCfg org, CancellationToken ct) + { + var now = DateTime.UtcNow; + + // Все открытые минусы орг. с названиями товара/склада — одним запросом. + var open = await (from a in _db.NegativeStockAlerts + where a.ResolvedAt == null + join p in _db.Products on a.ProductId equals p.Id + join s in _db.Stores on a.StoreId equals s.Id + select new OpenAlert(a.Id, a.StoreId, s.Name, a.ProductId, p.Name, + a.Quantity, a.FirstDetectedAt, a.NotifiedAt, a.EscalatedAt)) + .ToListAsync(ct); + if (open.Count == 0) return; + + // Получателей считаем один раз на орг (не на склад) — списки совпадают. + var recipients = await ResolveRecipientsAsync(org.OwnerUserId, ct); + + // ── 1. Агрегация по складам ─────────────────────────────────────────── + var quiet = InQuietHours(now, org.QuietFrom, org.QuietTo); + if (!quiet) + { + foreach (var g in open.GroupBy(a => new { a.StoreId, a.StoreName })) + { + var hasNew = g.Any(a => a.NotifiedAt == null); + if (!hasNew) continue; + var lastNotified = g.Where(a => a.NotifiedAt != null).Max(a => (DateTime?)a.NotifiedAt!.Value); + if (lastNotified is { } ln && (now - ln).TotalMinutes < org.IntervalMinutes) continue; + + await NotifyStoreAsync(org, g.Key.StoreId, g.Key.StoreName, g.ToList(), recipients, + escalation: false, ct); + + // Штампуем NotifiedAt на всех открытых алертах этого склада. + var ids = g.Select(a => a.Id).ToList(); + await _db.NegativeStockAlerts.Where(a => ids.Contains(a.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.NotifiedAt, now), ct); + } + } + + // ── 2. Эскалация просроченных (quiet hours игнорируем — срочно) ───────── + var overdueCutoff = now.AddHours(-org.EscalateHours); + foreach (var g in open.GroupBy(a => new { a.StoreId, a.StoreName })) + { + var overdue = g.Where(a => a.EscalatedAt == null && a.FirstDetectedAt <= overdueCutoff).ToList(); + if (overdue.Count == 0) continue; + + await NotifyStoreAsync(org, g.Key.StoreId, g.Key.StoreName, overdue, recipients, + escalation: true, ct); + + var ids = overdue.Select(a => a.Id).ToList(); + await _db.NegativeStockAlerts.Where(a => ids.Contains(a.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.EscalatedAt, now), ct); + } + } + + private sealed record OpenAlert(Guid Id, Guid StoreId, string StoreName, Guid ProductId, + string ProductName, decimal Quantity, DateTime FirstDetectedAt, DateTime? NotifiedAt, + DateTime? EscalatedAt); + + private async Task NotifyStoreAsync(OrgCfg org, Guid storeId, string storeName, + List alerts, IReadOnlyList recipients, bool escalation, CancellationToken ct) + { + var n = alerts.Count; + var title = escalation + ? $"Просрочено: {n} {Plural(n, "товар", "товара", "товаров")} в минусе на «{storeName}»" + : $"{n} {Plural(n, "товар ушёл", "товара ушли", "товаров ушли")} в минус на «{storeName}»"; + + var list = string.Join("\n", alerts.OrderBy(a => a.Quantity) + .Take(15).Select(a => $"• {a.ProductName}: {a.Quantity:0.###}")); + if (alerts.Count > 15) list += $"\n…и ещё {alerts.Count - 15}"; + var body = escalation + ? $"Минус не закрыт более {org.EscalateHours} ч. Срочно проведите инвентаризацию:\n{list}" + : $"Нужна инвентаризация. Товары в минусе:\n{list}"; + + var link = $"/inventory/stock?negativeOnly=true&storeId={storeId}"; + var dataJson = JsonSerializer.Serialize(new + { + storeId, storeName, count = n, escalation, + productIds = alerts.Select(a => a.ProductId).ToArray(), + }); + var type = escalation ? "negative-stock-overdue" : "negative-stock"; + + // Персистентно — по записи на получателя. + foreach (var uid in recipients) + { + _db.Notifications.Add(new Notification + { + OrganizationId = org.Id, RecipientUserId = uid, Type = type, + Severity = "warning", Title = title, Body = body, Link = link, + DataJson = dataJson, StoreId = storeId, + }); + } + await _db.SaveChangesAsync(ct); + + // Живой колокольчик (SignalR) — фронт по событию перечитывает счётчик/список. + await _publisher.PublishAsync(org.Id, NotificationEvents.NotificationCreated, + new NotificationCreatedPayload(type, title, storeId, n, escalation)); + + // Push-заглушка (реальный APNs/FCM позже). + try { await _push.SendAsync(recipients, title, $"{n} товаров в минусе — нужна инвентаризация", null, ct); } + catch (Exception ex) { _log.LogDebug(ex, "push stub failed"); } + + // Telegram — владельцу org (если привязан). + if (org.OwnerChatId is { } chatId && _tg.IsEnabled) + { + var tgText = $"{WebUtility.HtmlEncode(title)}\n{WebUtility.HtmlEncode(body)}"; + await _tg.SendMessageAsync(chatId, tgText, ct); + } + } + + /// Получатели: администраторы (роль Admin), владелец аккаунта и + /// кладовщики (сотрудники с правом InventoryEdit и привязанным логином). + private async Task> ResolveRecipientsAsync(Guid? ownerUserId, CancellationToken ct) + { + var set = new HashSet(); + if (ownerUserId is { } oid) set.Add(oid); + + var adminUserIds = await _db.UserRoles.AsNoTracking() + .Join(_db.Roles.AsNoTracking(), ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name }) + .Where(x => x.Name == "Admin") + .Select(x => x.UserId).ToListAsync(ct); + var activeAdmins = await _db.Users.Where(u => adminUserIds.Contains(u.Id) && u.IsActive) + .Select(u => u.Id).ToListAsync(ct); + foreach (var id in activeAdmins) set.Add(id); + + // Кладовщики: Permissions — owned JSON, предикат по нему EF не переводит, + // поэтому тянем активных сотрудников с логином в память (орг. небольшие). + var keepers = await _db.Employees + .Where(e => e.IsActive && !e.IsDeleted && e.UserId != null) + .Select(e => new { e.UserId, e.Role.Permissions.InventoryEdit }) + .ToListAsync(ct); + foreach (var k in keepers.Where(k => k.InventoryEdit && k.UserId != null)) set.Add(k.UserId!.Value); + + return set.ToList(); + } + + /// Текущий час в зоне Asia/Almaty (UTC+5) внутри [from,to)? + /// Интервал может переходить через полночь. from==to или любой null → нет тихих часов. + public static bool InQuietHours(DateTime utcNow, int? from, int? to) + { + if (from is null || to is null || from == to) return false; + var hour = (utcNow.Hour + KzUtcOffsetHours) % 24; + int f = ((from.Value % 24) + 24) % 24, t = ((to.Value % 24) + 24) % 24; + return f < t ? (hour >= f && hour < t) : (hour >= f || hour < t); + } + + private static string Plural(int n, string one, string few, string many) + { + var mod100 = n % 100; var mod10 = n % 10; + if (mod100 is >= 11 and <= 14) return many; + return mod10 switch { 1 => one, >= 2 and <= 4 => few, _ => many }; + } +} diff --git a/src/food-market.api/Controllers/NotificationsController.cs b/src/food-market.api/Controllers/NotificationsController.cs new file mode 100644 index 0000000..ea9c0bf --- /dev/null +++ b/src/food-market.api/Controllers/NotificationsController.cs @@ -0,0 +1,95 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers; + +/// Центр уведомлений: персистентные уведомления текущего пользователя +/// (в отличие от эфемерных SignalR-событий). Веб-колокольчик и мобильное +/// приложение читают отсюда список/счётчик и помечают прочитанными. +/// +/// Наполняется фоновым джобом (NegativeStockNotificationJob) — агрегат +/// «N товаров ушли в минус» на склад + эскалация просроченных минусов. +[ApiController] +[Authorize] +[Route("api/notifications")] +public class NotificationsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + + public NotificationsController(AppDbContext db, ITenantContext tenant) + { + _db = db; + _tenant = tenant; + } + + public record NotificationRow( + Guid id, string type, string severity, string title, string body, + string? link, string? dataJson, Guid? storeId, bool read, DateTime createdAt); + + public record ListResult(IReadOnlyList items, int unread, int total); + + /// Список уведомлений текущего пользователя, свежие сверху. + /// — только непрочитанные; — лимит. + [HttpGet] + public async Task> List( + [FromQuery] bool unreadOnly = false, [FromQuery] int take = 50, CancellationToken ct = default) + { + var userId = _tenant.UserId; + if (userId is null) return Unauthorized(); + take = Math.Clamp(take, 1, 200); + + var mine = _db.Notifications.Where(n => n.RecipientUserId == userId.Value); + var unread = await mine.CountAsync(n => n.ReadAt == null, ct); + var total = await mine.CountAsync(ct); + + var q = mine; + if (unreadOnly) q = q.Where(n => n.ReadAt == null); + var rows = await q.OrderByDescending(n => n.CreatedAt).Take(take) + .Select(n => new NotificationRow( + n.Id, n.Type, n.Severity, n.Title, n.Body, n.Link, n.DataJson, n.StoreId, + n.ReadAt != null, n.CreatedAt)) + .ToListAsync(ct); + return new ListResult(rows, unread, total); + } + + /// Счётчик непрочитанных — для бейджа на колокольчике. + [HttpGet("count")] + public async Task> UnreadCount(CancellationToken ct) + { + var userId = _tenant.UserId; + if (userId is null) return Unauthorized(); + var unread = await _db.Notifications.CountAsync( + n => n.RecipientUserId == userId.Value && n.ReadAt == null, ct); + return Ok(new { unread }); + } + + /// Пометить одно уведомление прочитанным. + [HttpPost("{id:guid}/read")] + public async Task MarkRead(Guid id, CancellationToken ct) + { + var userId = _tenant.UserId; + if (userId is null) return Unauthorized(); + var n = await _db.Notifications.FirstOrDefaultAsync( + x => x.Id == id && x.RecipientUserId == userId.Value, ct); + if (n is null) return NotFound(); + if (n.ReadAt is null) { n.ReadAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); } + return NoContent(); + } + + /// Пометить все уведомления пользователя прочитанными. + [HttpPost("read-all")] + public async Task MarkAllRead(CancellationToken ct) + { + var userId = _tenant.UserId; + if (userId is null) return Unauthorized(); + var now = DateTime.UtcNow; + await _db.Notifications + .Where(n => n.RecipientUserId == userId.Value && n.ReadAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, now), ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index ca7fcb6..229047f 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -43,7 +43,13 @@ public record OrgSettingsDto( bool WeightBarcodeEnabled, string? WeightBarcodeWeightPrefixes, string? WeightBarcodePricePrefixes, - int WeightBarcodeCodeDigits); + int WeightBarcodeCodeDigits, + // Уведомления об отрицательных остатках. + bool NegStockNotifyEnabled, + int NegStockNotifyIntervalMinutes, + int? NegStockQuietFromHour, + int? NegStockQuietToHour, + int NegStockEscalateHours); // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). public record OrgSettingsInput( @@ -65,7 +71,14 @@ public record OrgSettingsInput( bool? WeightBarcodeEnabled = null, string? WeightBarcodeWeightPrefixes = null, string? WeightBarcodePricePrefixes = null, - int? WeightBarcodeCodeDigits = null); + int? WeightBarcodeCodeDigits = null, + // Уведомления об отрицательных остатках — опциональный блок (применяется + // только если задан NegStockNotifyEnabled; round-trip-безопасно). + bool? NegStockNotifyEnabled = null, + int? NegStockNotifyIntervalMinutes = null, + int? NegStockQuietFromHour = null, + int? NegStockQuietToHour = null, + int? NegStockEscalateHours = null); [HttpGet("settings")] public async Task> Get(CancellationToken ct) @@ -112,6 +125,16 @@ public async Task> Update([FromBody] OrgSettingsInp o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim(); o.WeightBarcodeCodeDigits = input.WeightBarcodeCodeDigits is >= 1 and <= 8 ? input.WeightBarcodeCodeDigits.Value : 5; } + if (input.NegStockNotifyEnabled is bool nse) + { + o.NegStockNotifyEnabled = nse; + o.NegStockNotifyIntervalMinutes = input.NegStockNotifyIntervalMinutes is >= 1 and <= 1440 + ? input.NegStockNotifyIntervalMinutes.Value : 15; + o.NegStockQuietFromHour = NormalizeHour(input.NegStockQuietFromHour); + o.NegStockQuietToHour = NormalizeHour(input.NegStockQuietToHour); + o.NegStockEscalateHours = input.NegStockEscalateHours is >= 1 and <= 720 + ? input.NegStockEscalateHours.Value : 24; + } await _db.SaveChangesAsync(ct); await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); @@ -119,6 +142,9 @@ public async Task> Update([FromBody] OrgSettingsInp return Project(o, vat); } + /// Час 0–23 или null (значения вне диапазона → null = тихих часов нет). + private static int? NormalizeHour(int? h) => h is >= 0 and <= 23 ? h : null; + private async Task ReadVatRateAsync(string countryCode, CancellationToken ct) { var rate = await _db.Countries @@ -147,5 +173,10 @@ private async Task ReadVatRateAsync(string countryCode, CancellationTok o.WeightBarcodeEnabled, o.WeightBarcodeWeightPrefixes, o.WeightBarcodePricePrefixes, - o.WeightBarcodeCodeDigits); + o.WeightBarcodeCodeDigits, + o.NegStockNotifyEnabled, + o.NegStockNotifyIntervalMinutes, + o.NegStockQuietFromHour, + o.NegStockQuietToHour, + o.NegStockEscalateHours); } diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index ecfccb3..b428cbe 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -493,6 +493,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme c.Timeout = TimeSpan.FromSeconds(15); }); builder.Services.AddScoped(); + builder.Services.AddScoped(); // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio, // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs. diff --git a/src/food-market.api/Realtime/NotificationsPublisher.cs b/src/food-market.api/Realtime/NotificationsPublisher.cs index b333b75..2b52580 100644 --- a/src/food-market.api/Realtime/NotificationsPublisher.cs +++ b/src/food-market.api/Realtime/NotificationsPublisher.cs @@ -30,6 +30,10 @@ public static class NotificationEvents public const string SalePosted = "SalePosted"; /// Сразу после успешного post-a приёмки. public const string SupplyPosted = "SupplyPosted"; + /// Создано персистентное уведомление (центр уведомлений). Фронт по + /// событию перечитывает счётчик непрочитанных и список. Шлётся фоновым + /// джобом уведомлений об отрицательных остатках. + public const string NotificationCreated = "NotificationCreated"; /// Когда после движения остаток продукта стал ниже MinStock. /// Один event на каждый продукт-склад, в котором факт пересечения /// порога. Шлётся только когда было «выше» и стало «ниже» — иначе на @@ -61,3 +65,10 @@ public sealed record LowStockPayload( string? StoreName, decimal Quantity, decimal MinStock); + +public sealed record NotificationCreatedPayload( + string Type, + string Title, + Guid StoreId, + int Count, + bool Escalation); diff --git a/src/food-market.domain/Notifications/Notification.cs b/src/food-market.domain/Notifications/Notification.cs new file mode 100644 index 0000000..e3cca7f --- /dev/null +++ b/src/food-market.domain/Notifications/Notification.cs @@ -0,0 +1,45 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Notifications; + +/// Персистентное уведомление пользователю (в отличие от эфемерных +/// SignalR-событий). Центр уведомлений в вебе и мобильном API читают именно +/// эту таблицу: список, непрочитанные, пометка «прочитано». +/// +/// Создаются фоновым джобом (NegativeStockNotificationJob) — один +/// агрегированный элемент на склад раз в N минут, плюс эскалация по +/// просроченным минусам. Канал доставки (SignalR/Telegram/Web Push) — сверху, +/// сама запись хранится здесь независимо от канала. +public class Notification : TenantEntity +{ + /// Пользователь-получатель (AppUser.Id). Одно событие на склад + /// разворачивается в N записей — по одной на каждого получателя. + public Guid RecipientUserId { get; set; } + + /// Тип: negative-stock (агрегат по складу), + /// negative-stock-overdue (эскалация). Фронт по типу выбирает иконку. + public string Type { get; set; } = null!; + + /// Уровень: warning | info. Для подсветки. + public string Severity { get; set; } = "warning"; + + public string Title { get; set; } = null!; + + /// Тело — человекочитаемая сводка (может быть многострочной со + /// списком товаров). + public string Body { get; set; } = null!; + + /// Куда вести по клику, напр. /inventory/stock?negativeOnly=true&storeId=…. + public string? Link { get; set; } + + /// Структурированная нагрузка (JSON): storeId, count, список + /// productId — для мобильного приложения и действия «Пересчитать». + public string? DataJson { get; set; } + + /// Склад, к которому относится уведомление (для дедупликации/ + /// группировки агрегата). null для не-складских типов. + public Guid? StoreId { get; set; } + + /// Когда прочитано. null — непрочитано (учитывается в счётчике). + public DateTime? ReadAt { get; set; } +} diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index f43bc84..3c9a7e7 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -118,6 +118,28 @@ public class Organization : Entity /// Длина кода товара (PLU) внутри весовой этикетки. Default 5. public int WeightBarcodeCodeDigits { get; set; } = 5; + // ─ Уведомления об отрицательных остатках (идея владельца 08.10) ──────── + /// Слать ли уведомления «товары ушли в минус» (центр уведомлений, + /// Telegram, push). Default true. Выключение — только администратор. + public bool NegStockNotifyEnabled { get; set; } = true; + + /// Агрегация: не чаще одного уведомления на склад раз в N минут. + /// Default 15. Защита от спама при серии продаж в минус. + public int NegStockNotifyIntervalMinutes { get; set; } = 15; + + /// Тихие часы — час начала (0–23, время Asia/Almaty, UTC+5). + /// В интервале [From,To) уведомления копятся, но не шлются. null — тихих + /// часов нет. Если From==To — считается, что тихих часов нет. + public int? NegStockQuietFromHour { get; set; } + + /// Тихие часы — час конца (0–23). Интервал может переходить через + /// полночь (напр. From=22, To=8). + public int? NegStockQuietToHour { get; set; } + + /// Эскалация: если минус не закрыт за N часов — повторное + /// уведомление владельцу/админам + блок «Просроченные минусы». Default 24. + public int NegStockEscalateHours { get; set; } = 24; + // ─ Sprint 11: ОФД (фискализация чеков) ──────────────────────────── // Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant: // у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 187233b..2f1b76a 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -77,6 +77,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet EmployeeRoles => Set(); public DbSet DeviceTokens => Set(); + public DbSet Notifications => Set(); public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); public DbSet SuperAdminAuditLogs => Set(); @@ -207,6 +208,20 @@ protected override void OnModelCreating(ModelBuilder builder) b.HasIndex(x => x.FinishedAt); }); + builder.Entity(b => + { + b.ToTable("notifications"); + b.Property(x => x.Type).HasMaxLength(50).IsRequired(); + b.Property(x => x.Severity).HasMaxLength(20).IsRequired(); + b.Property(x => x.Title).HasMaxLength(300).IsRequired(); + b.Property(x => x.Body).HasMaxLength(4000).IsRequired(); + b.Property(x => x.Link).HasMaxLength(500); + b.Property(x => x.DataJson).HasColumnType("jsonb"); + // Центр уведомлений: непрочитанные конкретного пользователя, свежие сверху. + b.HasIndex(x => new { x.OrganizationId, x.RecipientUserId, x.ReadAt }); + b.HasIndex(x => new { x.OrganizationId, x.RecipientUserId, x.CreatedAt }); + }); + builder.ConfigureCatalog(); builder.ConfigureInventory(); builder.ConfigurePurchases(); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009030000_Phase41_Notifications.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009030000_Phase41_Notifications.cs new file mode 100644 index 0000000..a12f276 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009030000_Phase41_Notifications.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase41 — персистентные уведомления (notifications) + настройки + /// организации для уведомлений об отрицательных остатках (агрегация/тихие часы/ + /// эскалация). Идемпотентно (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS). + [DbContext(typeof(AppDbContext))] + [Migration("20261009030000_Phase41_Notifications")] + public partial class Phase41_Notifications : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.notifications ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""RecipientUserId"" uuid NOT NULL, + ""Type"" varchar(50) NOT NULL, + ""Severity"" varchar(20) NOT NULL, + ""Title"" varchar(300) NOT NULL, + ""Body"" varchar(4000) NOT NULL, + ""Link"" varchar(500), + ""DataJson"" jsonb, + ""StoreId"" uuid, + ""ReadAt"" timestamp with time zone, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone + ); + CREATE INDEX IF NOT EXISTS ""IX_notifications_Org_Recipient_ReadAt"" + ON public.notifications (""OrganizationId"", ""RecipientUserId"", ""ReadAt""); + CREATE INDEX IF NOT EXISTS ""IX_notifications_Org_Recipient_CreatedAt"" + ON public.notifications (""OrganizationId"", ""RecipientUserId"", ""CreatedAt""); + "); + + // Настройки организации под уведомления об отрицательных остатках. + b.Sql(@" + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""NegStockNotifyEnabled"" boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS ""NegStockNotifyIntervalMinutes"" integer NOT NULL DEFAULT 15, + ADD COLUMN IF NOT EXISTS ""NegStockQuietFromHour"" integer, + ADD COLUMN IF NOT EXISTS ""NegStockQuietToHour"" integer, + ADD COLUMN IF NOT EXISTS ""NegStockEscalateHours"" integer NOT NULL DEFAULT 24; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@"DROP TABLE IF EXISTS public.notifications;"); + b.Sql(@" + ALTER TABLE public.organizations + DROP COLUMN IF EXISTS ""NegStockNotifyEnabled"", + DROP COLUMN IF EXISTS ""NegStockNotifyIntervalMinutes"", + DROP COLUMN IF EXISTS ""NegStockQuietFromHour"", + DROP COLUMN IF EXISTS ""NegStockQuietToHour"", + DROP COLUMN IF EXISTS ""NegStockEscalateHours""; + "); + } + } +} diff --git a/src/food-market.web/src/components/NotificationCenter.tsx b/src/food-market.web/src/components/NotificationCenter.tsx index f30aade..fc2fee5 100644 --- a/src/food-market.web/src/components/NotificationCenter.tsx +++ b/src/food-market.web/src/components/NotificationCenter.tsx @@ -1,100 +1,95 @@ /** - * Sprint 18: In-app notification center. + * Центр уведомлений (колокольчик в сайдбаре). * - * Иконка-колокольчик в topbar (внутри AppLayout sidebar footer'a). Лоадит - * последние 30 SignalR-событий: SalePosted, SupplyPosted, LowStock. Хранит - * их в локальном state (без persist в БД — events ephemeral, при reload - * пропадают, что соответствует UX «активная сессия»). + * Два источника: + * 1. ПЕРСИСТЕНТНЫЕ уведомления из `/api/notifications` — главный фид. Сюда + * фоновый джоб кладёт агрегат «N товаров ушли в минус» на склад и эскалацию + * просроченных минусов. Непрочитанные считаются на сервере; открытие + * колокольчика помечает все прочитанными (read-all). + * 2. ЭФЕМЕРНЫЕ live-события (SalePosted/SupplyPosted) из SignalR — «что + * произошло, пока вы смотрели»; не персистятся, при reload пропадают. * - * Counter показывает кол-во непрочитанных. Клик по колокольчику открывает - * popover; открытие сбрасывает непрочитанный счётчик. Каждое событие — - * clickable: ведёт на соответствующий документ. - * - * Принципиально НЕ персистится: если юзеру нужна история — есть - * `/audit-log` и листинги (`/sales/retail`, `/purchases/supplies`). - * Центр уведомлений — это «что произошло, пока вы тут смотрели». + * SignalR-событие NotificationCreated инвалидирует фид (живой колокольчик без + * перезагрузки) и счётчик минусов. */ import { useState, useEffect, useRef } from 'react' -import { Link } from 'react-router-dom' -import { Bell, ShoppingCart, TruckIcon, AlertTriangle, X } from 'lucide-react' -import { useNotificationsHub, type SalePostedPayload, type SupplyPostedPayload, type LowStockPayload } from '@/lib/useNotificationsHub' +import { Link, useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { Bell, ShoppingCart, TruckIcon, AlertTriangle, X, Loader2 } from 'lucide-react' +import { useNotificationsHub, type SalePostedPayload, type SupplyPostedPayload } from '@/lib/useNotificationsHub' +import { useNotifications, useMarkAllNotificationsRead, type NotificationRow } from '@/lib/useNotifications' import { useFormatCurrency } from '@/lib/useFormatCurrency' +import { api } from '@/lib/api' -type Notification = - | { kind: 'sale'; id: string; at: string; payload: SalePostedPayload; read: boolean } - | { kind: 'supply'; id: string; at: string; payload: SupplyPostedPayload; read: boolean } - | { kind: 'low-stock'; id: string; at: string; payload: LowStockPayload; read: boolean } +type Live = + | { kind: 'sale'; id: string; at: string; payload: SalePostedPayload } + | { kind: 'supply'; id: string; at: string; payload: SupplyPostedPayload } -const MAX_NOTIFICATIONS = 30 +const MAX_LIVE = 20 export function NotificationCenter() { - const [items, setItems] = useState([]) + const [live, setLive] = useState([]) const [open, setOpen] = useState(false) const popoverRef = useRef(null) const fmtMoney = useFormatCurrency() + const qc = useQueryClient() + const navigate = useNavigate() + const [recounting, setRecounting] = useState(null) + + const { data } = useNotifications() + const markAllRead = useMarkAllNotificationsRead() + const persisted = data?.items ?? [] + const serverUnread = data?.unread ?? 0 - // Подписываемся на 3 типа событий через существующий useNotificationsHub. - // Каждое событие prepend'ится в ленту с уникальным id (на случай дубликатов - // от reconnect'ов используем kind+payloadId; при коллизии второй просто - // не добавится). useNotificationsHub({ - onSalePosted: (p) => { - setItems((prev) => prepend(prev, { - kind: 'sale', id: `sale-${p.saleId}`, at: p.postedAt, payload: p, read: false, - })) - }, - onSupplyPosted: (p) => { - setItems((prev) => prepend(prev, { - kind: 'supply', id: `supply-${p.supplyId}`, at: p.postedAt, payload: p, read: false, - })) - }, - onLowStock: (p) => { - // LowStock без productId+storeId-уникальности задрочит ленту дубликатами - // на каждом приходе → ключ включает оба поля + округлённое время. - const slot = Math.floor(Date.now() / 60000) // одна минута = одна запись - setItems((prev) => prepend(prev, { - kind: 'low-stock', id: `lowstock-${p.productId}-${p.storeId}-${slot}`, - at: new Date().toISOString(), payload: p, read: false, - })) + onSalePosted: (p) => setLive((prev) => prependLive(prev, { kind: 'sale', id: `sale-${p.saleId}`, at: p.postedAt, payload: p })), + onSupplyPosted: (p) => setLive((prev) => prependLive(prev, { kind: 'supply', id: `supply-${p.supplyId}`, at: p.postedAt, payload: p })), + // Фоновый джоб создал персистентное уведомление → обновляем фид и счётчик минусов. + onNotificationCreated: () => { + qc.invalidateQueries({ queryKey: ['/api/notifications'] }) + qc.invalidateQueries({ queryKey: ['/api/negative-stock/count'] }) }, }) - // Click-outside закрывает popover (но клики внутри ссылок — наоборот - // должны его закрыть после навигации, поэтому стрелки на ссылках вызывают setOpen(false)). useEffect(() => { if (!open) return function onClick(e: MouseEvent) { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { - setOpen(false) - } - } - function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') setOpen(false) + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) setOpen(false) } + function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', onClick) document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onClick) - document.removeEventListener('keydown', onKey) - } + return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey) } }, [open]) - const unreadCount = items.filter((i) => !i.read).length + const unreadCount = serverUnread + live.length function toggleOpen() { setOpen((x) => { const next = !x - // При открытии — помечаем все как прочитанные. - if (next) setItems((prev) => prev.map((i) => ({ ...i, read: true }))) + if (next && serverUnread > 0) markAllRead.mutate() + if (next) setLive([]) // live-события «просмотрены» return next }) } - function clear() { - setItems([]) - setOpen(false) + async function recount(n: NotificationRow) { + if (!n.storeId || recounting) return + setRecounting(n.id) + try { + const res = await api.post<{ inventoryId: string }>('/api/negative-stock/recount', { storeId: n.storeId }) + setOpen(false) + navigate(`/inventory/inventories/${res.data.inventoryId}`) + } catch { + setOpen(false) + navigate(n.link ?? '/inventory/stock?negativeOnly=true') + } finally { + setRecounting(null) + } } + const isEmpty = persisted.length === 0 && live.length === 0 + return (
{open && ( -
+

Уведомления

-
- {items.length > 0 && ( - - )} - -
+
- {items.length === 0 ? ( + {isEmpty ? (
Пока ничего нет.
- События появятся при проведении документов. + Здесь появятся предупреждения о товарах в минусе.
) : (
    - {items.map((n) => ( -
  • - {renderItem(n, fmtMoney, () => setOpen(false))} -
  • + {persisted.map((n) => ( +
  • {renderPersisted(n, recounting === n.id, () => recount(n), () => setOpen(false))}
  • + ))} + {live.map((n) => ( +
  • {renderLive(n, fmtMoney, () => setOpen(false))}
  • ))}
)} @@ -166,77 +139,64 @@ export function NotificationCenter() { ) } -function prepend(prev: Notification[], n: Notification): Notification[] { - // Уникализация по id — если событие уже есть (reconnect-replay), пропускаем. +function prependLive(prev: Live[], n: Live): Live[] { if (prev.some((p) => p.id === n.id)) return prev - return [n, ...prev].slice(0, MAX_NOTIFICATIONS) + return [n, ...prev].slice(0, MAX_LIVE) } -function renderItem( - n: Notification, - fmtMoney: ReturnType, - closeMenu: () => void, -) { +function renderPersisted(n: NotificationRow, busy: boolean, onRecount: () => void, closeMenu: () => void) { + const ts = new Date(n.createdAt).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) + const overdue = n.type === 'negative-stock-overdue' + const canRecount = (n.type === 'negative-stock' || overdue) && !!n.storeId + return ( +
+
+
+
+ ) +} + +function renderLive(n: Live, fmtMoney: ReturnType, closeMenu: () => void) { const ts = new Date(n.at).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }) if (n.kind === 'sale') { return ( - +
) } - if (n.kind === 'supply') { - return ( - -
-
- - ) - } - // low-stock return ( - +
-
diff --git a/src/food-market.web/src/components/OverdueNegativeStockBlock.tsx b/src/food-market.web/src/components/OverdueNegativeStockBlock.tsx new file mode 100644 index 0000000..777c306 --- /dev/null +++ b/src/food-market.web/src/components/OverdueNegativeStockBlock.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { AlertTriangle, Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { useNegativeStockAlerts } from '@/lib/useNegativeStock' + +/** + * Блок «Просроченные минусы» на главной (item 11 эскалации): товары, что висят + * в минусе дольше порога (overdue=true приходит из API). Пустой — не рендерится. + * «Пересчитать» создаёт черновик инвентаризации по складу. + */ +export function OverdueNegativeStockBlock() { + const { data } = useNegativeStockAlerts() + const navigate = useNavigate() + const [busy, setBusy] = useState(null) + + const overdue = (data ?? []).filter((a) => a.overdue) + if (overdue.length === 0) return null + + const byStore = new Map() + for (const a of overdue) { + const e = byStore.get(a.storeId) ?? { storeName: a.storeName, items: [] as typeof overdue } + e.items.push(a) + byStore.set(a.storeId, e) + } + + const recount = async (storeId: string) => { + if (busy) return + setBusy(storeId) + try { + const res = await api.post<{ inventoryId: string }>('/api/negative-stock/recount', { storeId }) + navigate(`/inventory/inventories/${res.data.inventoryId}`) + } catch { + navigate(`/inventory/stock?negativeOnly=true&storeId=${storeId}`) + } finally { + setBusy(null) + } + } + + return ( +
+
+
+
+ {[...byStore.entries()].map(([storeId, { storeName, items }]) => ( +
+ {storeName}: + + {items.slice(0, 5).map((a) => `${a.productName} (${a.quantity})`).join(', ')} + {items.length > 5 ? ` …и ещё ${items.length - 5}` : ''} + + +
+ ))} +
+
+ ) +} diff --git a/src/food-market.web/src/lib/useNotifications.ts b/src/food-market.web/src/lib/useNotifications.ts new file mode 100644 index 0000000..da51be8 --- /dev/null +++ b/src/food-market.web/src/lib/useNotifications.ts @@ -0,0 +1,48 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from './api' + +export interface NotificationRow { + id: string + type: string + severity: string + title: string + body: string + link: string | null + dataJson: string | null + storeId: string | null + read: boolean + createdAt: string +} + +export interface NotificationsList { + items: NotificationRow[] + unread: number + total: number +} + +/** Персистентные уведомления текущего пользователя (центр уведомлений). + * Поллинг каждые 60с + инвалидация по SignalR-событию NotificationCreated. */ +export function useNotifications(take = 50) { + return useQuery({ + queryKey: ['/api/notifications', take], + queryFn: async () => (await api.get(`/api/notifications?take=${take}`)).data, + refetchInterval: 60_000, + staleTime: 15_000, + }) +} + +export function useMarkNotificationRead() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (id: string) => { await api.post(`/api/notifications/${id}/read`) }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/notifications'] }), + }) +} + +export function useMarkAllNotificationsRead() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async () => { await api.post('/api/notifications/read-all') }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/notifications'] }), + }) +} diff --git a/src/food-market.web/src/lib/useNotificationsHub.ts b/src/food-market.web/src/lib/useNotificationsHub.ts index 9dadd59..c2e0f83 100644 --- a/src/food-market.web/src/lib/useNotificationsHub.ts +++ b/src/food-market.web/src/lib/useNotificationsHub.ts @@ -49,10 +49,19 @@ export interface LowStockPayload { minStock: number } +export interface NotificationCreatedPayload { + type: string + title: string + storeId: string + count: number + escalation: boolean +} + export interface NotificationsHandlers { onSalePosted?: (p: SalePostedPayload) => void onSupplyPosted?: (p: SupplyPostedPayload) => void onLowStock?: (p: LowStockPayload) => void + onNotificationCreated?: (p: NotificationCreatedPayload) => void } export function useNotificationsHub(handlers: NotificationsHandlers) { @@ -75,6 +84,7 @@ export function useNotificationsHub(handlers: NotificationsHandlers) { if (handlers.onSalePosted) conn.on('SalePosted', handlers.onSalePosted) if (handlers.onSupplyPosted) conn.on('SupplyPosted', handlers.onSupplyPosted) if (handlers.onLowStock) conn.on('LowStock', handlers.onLowStock) + if (handlers.onNotificationCreated) conn.on('NotificationCreated', handlers.onNotificationCreated) conn.onreconnected(() => setIsConnected(true)) conn.onreconnecting(() => setIsConnected(false)) conn.onclose(() => setIsConnected(false)) diff --git a/src/food-market.web/src/lib/useOrgSettings.ts b/src/food-market.web/src/lib/useOrgSettings.ts index 2991dbf..f7e7d50 100644 --- a/src/food-market.web/src/lib/useOrgSettings.ts +++ b/src/food-market.web/src/lib/useOrgSettings.ts @@ -18,6 +18,12 @@ export interface OrgSettings { showReferencePriceOnProduct: boolean showCountryOfOriginOnProduct: boolean showDescriptionOnProduct: boolean + // Уведомления об отрицательных остатках. + negStockNotifyEnabled: boolean + negStockNotifyIntervalMinutes: number + negStockQuietFromHour: number | null + negStockQuietToHour: number | null + negStockEscalateHours: number } export function useOrgSettings() { diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index 8862744..2bee7ec 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -3,6 +3,7 @@ import { lazy, Suspense, useState } from 'react' import { useTranslation } from 'react-i18next' import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff, CalendarDays } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' +import { OverdueNegativeStockBlock } from '@/components/OverdueNegativeStockBlock' import { Skeleton } from '@/components/Skeleton' import { api } from '@/lib/api' import { toast } from '@/lib/toast' @@ -159,6 +160,9 @@ export function DashboardPage() { )} /> + {/* Эскалация: товары, висящие в минусе дольше порога (item 11). */} + + {/* KPI блок продажи: today / week / month + prev-month сравнение */}
('/api/organization/settings', payload)).data }, @@ -196,6 +201,43 @@ export function OrganizationSettingsPage() { По умолчанию выключено — описания захламляют карточку. Включай если ведёшь подробные тексты на товарах.

+ +
+

+ Уведомления об отрицательных остатках +

+

+ Продажи/списания в минус разрешены, но система предупреждает и требует + инвентаризации. Получатели — администраторы и кладовщики. +

+ setForm({ ...form, negStockNotifyEnabled: v })} + /> + {form.negStockNotifyEnabled && ( +
+ + setForm({ ...form, negStockNotifyIntervalMinutes: Number(e.target.value) || 15 })} /> + + + setForm({ ...form, negStockEscalateHours: Number(e.target.value) || 24 })} /> + + + setForm({ ...form, negStockQuietFromHour: e.target.value === '' ? null : Number(e.target.value) })} /> + + + setForm({ ...form, negStockQuietToHour: e.target.value === '' ? null : Number(e.target.value) })} /> + +
+ )} +
diff --git a/tests/food-market.IntegrationTests/NegativeStockTests.cs b/tests/food-market.IntegrationTests/NegativeStockTests.cs index 38f7db2..dc060c4 100644 --- a/tests/food-market.IntegrationTests/NegativeStockTests.cs +++ b/tests/food-market.IntegrationTests/NegativeStockTests.cs @@ -1,7 +1,9 @@ using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; +using foodmarket.Api.Background; using foodmarket.IntegrationTests.Support; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace foodmarket.IntegrationTests; @@ -104,4 +106,57 @@ public async Task Negative_stock_report_filter_returns_only_negatives() items.Should().NotBeEmpty(); items.Should().OnlyContain(x => x.GetProperty("quantity").GetDecimal() < 0); } + + [Fact] + public async Task Notify_job_creates_persistent_notification_for_admin_and_marks_read() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"neg-notif-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Уходим в минус (−4). + await SeedStockAsync(api, refs, p, 1m); + var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + reason = 0, notes = "minus", lines = new[] { new { productId = p, quantity = 5m, unitCost = 50m } }, + }); + loss.EnsureSuccessStatusCode(); + var lid = (await loss.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { })).EnsureSuccessStatusCode(); + + // До прогона джоба — уведомлений нет. + (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0); + + // Прогоняем фоновый джоб (агрегация по складам) напрямую. + using (var scope = _factory.Services.CreateScope()) + { + var job = scope.ServiceProvider.GetRequiredService(); + await job.RunAsync(); + } + + // Админу (он же владелец) создано персистентное уведомление «в минус». + var list = await api.GetJsonAsync("/api/notifications"); + list.GetProperty("unread").GetInt32().Should().BeGreaterThanOrEqualTo(1); + var mine = list.GetProperty("items").EnumerateArray() + .Where(x => x.GetProperty("type").GetString() == "negative-stock").ToList(); + mine.Should().NotBeEmpty("джоб агрегирует минусы по складу в одно уведомление"); + mine[0].GetProperty("title").GetString().Should().Contain("минус"); + mine[0].GetProperty("storeId").GetString().Should().Be(refs.StoreId); + + // Повторный прогон в пределах интервала — новых уведомлений по складу нет (дебаунс). + var unreadAfterFirst = (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32(); + using (var scope = _factory.Services.CreateScope()) + { + var job = scope.ServiceProvider.GetRequiredService(); + await job.RunAsync(); + } + (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32() + .Should().Be(unreadAfterFirst, "дебаунс: в пределах интервала повторно не шлём"); + + // Пометка «прочитано всё» обнуляет счётчик. + (await api.Http.PostAsJsonAsync("/api/notifications/read-all", new { })).EnsureSuccessStatusCode(); + (await api.GetJsonAsync("/api/notifications/count")).GetProperty("unread").GetInt32().Should().Be(0); + } } diff --git a/tests/food-market.UnitTests/NegativeStockNotifyQuietHoursTests.cs b/tests/food-market.UnitTests/NegativeStockNotifyQuietHoursTests.cs new file mode 100644 index 0000000..8a638ff --- /dev/null +++ b/tests/food-market.UnitTests/NegativeStockNotifyQuietHoursTests.cs @@ -0,0 +1,46 @@ +using System; +using foodmarket.Api.Background; +using FluentAssertions; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Тихие часы агрегированной рассылки уведомлений об отрицательных +/// остатках. Время считается в зоне Asia/Almaty (UTC+5). Интервал может +/// переходить через полночь; null/равные границы = тихих часов нет. +public class NegativeStockNotifyQuietHoursTests +{ + // 03:00 UTC = 08:00 Алматы. + private static readonly DateTime At08Almaty = new(2026, 10, 9, 3, 0, 0, DateTimeKind.Utc); + // 18:00 UTC = 23:00 Алматы. + private static readonly DateTime At23Almaty = new(2026, 10, 9, 18, 0, 0, DateTimeKind.Utc); + // 21:00 UTC = 02:00 Алматы (следующих суток). + private static readonly DateTime At02Almaty = new(2026, 10, 9, 21, 0, 0, DateTimeKind.Utc); + + [Fact] + public void No_quiet_hours_when_null_or_equal() + { + NegativeStockNotificationJob.InQuietHours(At23Almaty, null, 8).Should().BeFalse(); + NegativeStockNotificationJob.InQuietHours(At23Almaty, 22, null).Should().BeFalse(); + NegativeStockNotificationJob.InQuietHours(At23Almaty, 0, 0).Should().BeFalse(); + } + + [Fact] + public void Overnight_window_22_to_8_covers_late_night_and_early_morning() + { + // Тихие часы 22:00–08:00 (через полночь). + NegativeStockNotificationJob.InQuietHours(At23Almaty, 22, 8).Should().BeTrue(); // 23:00 — тихо + NegativeStockNotificationJob.InQuietHours(At02Almaty, 22, 8).Should().BeTrue(); // 02:00 — тихо + NegativeStockNotificationJob.InQuietHours(At08Almaty, 22, 8).Should().BeFalse(); // 08:00 — уже можно + } + + [Fact] + public void Daytime_window_9_to_18_is_same_day_range() + { + NegativeStockNotificationJob.InQuietHours(At08Almaty, 9, 18).Should().BeFalse(); // 08:00 — вне окна + NegativeStockNotificationJob.InQuietHours(At23Almaty, 9, 18).Should().BeFalse(); // 23:00 — вне окна + // 13:00 Алматы = 08:00 UTC… проверим середину окна: 10:00 UTC = 15:00 Алматы. + var at15 = new DateTime(2026, 10, 9, 10, 0, 0, DateTimeKind.Utc); + NegativeStockNotificationJob.InQuietHours(at15, 9, 18).Should().BeTrue(); + } +} diff --git a/tests/food-market.UnitTests/NoopNegativeStockAlertService.cs b/tests/food-market.UnitTests/NoopNegativeStockAlertService.cs new file mode 100644 index 0000000..4f72b4f --- /dev/null +++ b/tests/food-market.UnitTests/NoopNegativeStockAlertService.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using foodmarket.Application.Inventory; + +namespace foodmarket.UnitTests; + +/// No-op реализация для юнит-тестов +/// StockService, которым неважны предупреждения об отрицательных остатках (они +/// проверяют только арифметику остатков). Поведение алертов покрыто отдельными +/// интеграционными тестами (NegativeStockTests). +public sealed class NoopNegativeStockAlertService : INegativeStockAlertService +{ + public static readonly NoopNegativeStockAlertService Instance = new(); + + public Task OnStockLevelAsync( + Guid storeId, Guid productId, decimal newQty, + string? causeDocType, Guid? causeDocId, string? causeDocNumber, + Guid? causeUserId, DateTime? causeAt, + CancellationToken ct = default) => Task.CompletedTask; +} diff --git a/tests/food-market.UnitTests/StockServicePropertyTests.cs b/tests/food-market.UnitTests/StockServicePropertyTests.cs index d8f3a32..9a9a0d0 100644 --- a/tests/food-market.UnitTests/StockServicePropertyTests.cs +++ b/tests/food-market.UnitTests/StockServicePropertyTests.cs @@ -59,7 +59,7 @@ public async Task Sum_of_movements_equals_stock_quantity(int seed, int count) } using var db = sqlite.Create(tenant); - var svc = new StockService(db, tenant); + var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance); for (var i = 0; i < movements.Count; i++) { var qty = movements[i]; @@ -124,7 +124,7 @@ public async Task Batch_apply_distinct_products_gives_expected_stocks(int seed, } using var db = sqlite.Create(tenant); - var svc = new StockService(db, tenant); + var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance); await svc.ApplyMovementsAsync(drafts); await db.SaveChangesAsync(); @@ -147,7 +147,7 @@ public async Task Two_products_in_same_store_dont_interfere() var p2 = Guid.NewGuid(); using var db = sqlite.Create(tenant); - var svc = new StockService(db, tenant); + var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance); // Каждый ApplyMovementAsync + SaveChangesAsync — отдельная «проводка», // как делают контроллеры. Без SaveChanges между вызовами на одном // ProductId второй вызов не увидит pending-Stock и попытается diff --git a/tests/food-market.UnitTests/StockServiceTests.cs b/tests/food-market.UnitTests/StockServiceTests.cs index 1b8c7ba..6a7c216 100644 --- a/tests/food-market.UnitTests/StockServiceTests.cs +++ b/tests/food-market.UnitTests/StockServiceTests.cs @@ -28,7 +28,7 @@ public async Task Creates_stock_and_movement_on_first_application() decimal returned; using (var db = sqlite.Create(tenant)) { - returned = await new StockService(db, tenant).ApplyMovementAsync(Draft(product, store, 10m)); + returned = await new StockService(db, tenant, NoopNegativeStockAlertService.Instance).ApplyMovementAsync(Draft(product, store, 10m)); await db.SaveChangesAsync(); } @@ -53,7 +53,7 @@ public async Task Second_application_increments_existing_stock() var store = Guid.NewGuid(); using var db = sqlite.Create(tenant); - var svc = new StockService(db, tenant); + var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance); await svc.ApplyMovementAsync(Draft(product, store, 10m)); await db.SaveChangesAsync(); @@ -74,7 +74,7 @@ public async Task Negative_movement_decrements_stock() var store = Guid.NewGuid(); using var db = sqlite.Create(tenant); - var svc = new StockService(db, tenant); + var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance); await svc.ApplyMovementAsync(Draft(product, store, 10m)); await db.SaveChangesAsync(); @@ -90,7 +90,7 @@ public async Task Throws_when_tenant_not_set() using var sqlite = new SqliteDb(foreignKeys: false); var tenant = new FakeTenantContext { OrganizationId = null }; using var db = sqlite.Create(tenant); - var svc = new StockService(db, tenant); + var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance); await FluentActions .Awaiting(() => svc.ApplyMovementAsync(Draft(Guid.NewGuid(), Guid.NewGuid(), 1m)))