feat(notifications): центр уведомлений + агрегация/эскалация минусов + каналы
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Персистентные уведомления (таблица 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 02:56:59 +05:00
parent 242f48ff9f
commit 29a951a6ff
22 changed files with 939 additions and 159 deletions

View file

@ -143,6 +143,16 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronTgDaily, cronExpression: cronTgDaily,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Уведомления об отрицательных остатках: агрегированная рассылка + эскалация.
// Бежит часто (каждые 5 минут), сам дебаунсит через NotifiedAt и настройку
// интервала/тихих часов организации.
var cronNegStock = _cfg["Hangfire:Cron:NegativeStockNotify"] ?? "*/5 * * * *";
_jobs.AddOrUpdate<NegativeStockNotificationJob>(
recurringJobId: "negative-stock-notify",
methodCall: j => j.RunAsync(CancellationToken.None),
cronExpression: cronNegStock,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
return Task.CompletedTask; return Task.CompletedTask;
} }

View file

@ -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;
/// <summary>Фоновый джоб уведомлений об отрицательных остатках. Бежит часто
/// (cron каждые 5 минут), но сам дебаунсит через <c>NegativeStockAlert.NotifiedAt</c>:
///
/// 1. <b>Агрегация</b> — по каждому складу, где появились новые минусы
/// (NotifiedAt == null) и с прошлой рассылки по складу прошло ≥ N минут
/// (настройка орг., по умолч. 15), шлём ОДНО уведомление «N товаров ушли в
/// минус» со списком. Получатели — администраторы, владелец и кладовщики
/// (роль с InventoryEdit). Тихие часы (настройка) — рассылку откладываем.
/// 2. <b>Эскалация</b> — минус, не закрытый за N часов (настройка, по умолч. 24)
/// и ещё не эскалированный (EscalatedAt == null), повторно шлём владельцу/
/// админам как срочный. Эскалация тихие часы игнорирует (срочно).
///
/// Запись уведомления персистентная (таблица notifications → центр уведомлений
/// в вебе и мобильном API). Дополнительно пушим SignalR-событие (живой
/// колокольчик), Telegram (владельцу) и push-заглушку.</summary>
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<NegativeStockNotificationJob> _log;
public NegativeStockNotificationJob(
AppDbContext db, INotificationsPublisher publisher, ITelegramBotClient tg,
IPushSender push, ILogger<NegativeStockNotificationJob> 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<OpenAlert> alerts, IReadOnlyList<Guid> 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 = $"<b>{WebUtility.HtmlEncode(title)}</b>\n{WebUtility.HtmlEncode(body)}";
await _tg.SendMessageAsync(chatId, tgText, ct);
}
}
/// <summary>Получатели: администраторы (роль Admin), владелец аккаунта и
/// кладовщики (сотрудники с правом InventoryEdit и привязанным логином).</summary>
private async Task<IReadOnlyList<Guid>> ResolveRecipientsAsync(Guid? ownerUserId, CancellationToken ct)
{
var set = new HashSet<Guid>();
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();
}
/// <summary>Текущий час в зоне Asia/Almaty (UTC+5) внутри [from,to)?
/// Интервал может переходить через полночь. from==to или любой null → нет тихих часов.</summary>
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 };
}
}

View file

@ -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;
/// <summary>Центр уведомлений: персистентные уведомления текущего пользователя
/// (в отличие от эфемерных SignalR-событий). Веб-колокольчик и мобильное
/// приложение читают отсюда список/счётчик и помечают прочитанными.
///
/// Наполняется фоновым джобом (<c>NegativeStockNotificationJob</c>) — агрегат
/// «N товаров ушли в минус» на склад + эскалация просроченных минусов.</summary>
[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<NotificationRow> items, int unread, int total);
/// <summary>Список уведомлений текущего пользователя, свежие сверху.
/// <paramref name="unreadOnly"/> — только непрочитанные; <paramref name="take"/> — лимит.</summary>
[HttpGet]
public async Task<ActionResult<ListResult>> 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);
}
/// <summary>Счётчик непрочитанных — для бейджа на колокольчике.</summary>
[HttpGet("count")]
public async Task<ActionResult<object>> 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 });
}
/// <summary>Пометить одно уведомление прочитанным.</summary>
[HttpPost("{id:guid}/read")]
public async Task<IActionResult> 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();
}
/// <summary>Пометить все уведомления пользователя прочитанными.</summary>
[HttpPost("read-all")]
public async Task<IActionResult> 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();
}
}

View file

@ -43,7 +43,13 @@ public record OrgSettingsDto(
bool WeightBarcodeEnabled, bool WeightBarcodeEnabled,
string? WeightBarcodeWeightPrefixes, string? WeightBarcodeWeightPrefixes,
string? WeightBarcodePricePrefixes, string? WeightBarcodePricePrefixes,
int WeightBarcodeCodeDigits); int WeightBarcodeCodeDigits,
// Уведомления об отрицательных остатках.
bool NegStockNotifyEnabled,
int NegStockNotifyIntervalMinutes,
int? NegStockQuietFromHour,
int? NegStockQuietToHour,
int NegStockEscalateHours);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput( public record OrgSettingsInput(
@ -65,7 +71,14 @@ public record OrgSettingsInput(
bool? WeightBarcodeEnabled = null, bool? WeightBarcodeEnabled = null,
string? WeightBarcodeWeightPrefixes = null, string? WeightBarcodeWeightPrefixes = null,
string? WeightBarcodePricePrefixes = 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")] [HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct) public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -112,6 +125,16 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim(); o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim();
o.WeightBarcodeCodeDigits = input.WeightBarcodeCodeDigits is >= 1 and <= 8 ? input.WeightBarcodeCodeDigits.Value : 5; 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.SaveChangesAsync(ct);
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
@ -119,6 +142,9 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
return Project(o, vat); return Project(o, vat);
} }
/// <summary>Час 0–23 или null (значения вне диапазона → null = тихих часов нет).</summary>
private static int? NormalizeHour(int? h) => h is >= 0 and <= 23 ? h : null;
private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationToken ct) private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationToken ct)
{ {
var rate = await _db.Countries var rate = await _db.Countries
@ -147,5 +173,10 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.WeightBarcodeEnabled, o.WeightBarcodeEnabled,
o.WeightBarcodeWeightPrefixes, o.WeightBarcodeWeightPrefixes,
o.WeightBarcodePricePrefixes, o.WeightBarcodePricePrefixes,
o.WeightBarcodeCodeDigits); o.WeightBarcodeCodeDigits,
o.NegStockNotifyEnabled,
o.NegStockNotifyIntervalMinutes,
o.NegStockQuietFromHour,
o.NegStockQuietToHour,
o.NegStockEscalateHours);
} }

View file

@ -493,6 +493,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
c.Timeout = TimeSpan.FromSeconds(15); c.Timeout = TimeSpan.FromSeconds(15);
}); });
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>(); builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>();
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio, // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs. // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.

View file

@ -30,6 +30,10 @@ public static class NotificationEvents
public const string SalePosted = "SalePosted"; public const string SalePosted = "SalePosted";
/// <summary>Сразу после успешного post-a приёмки.</summary> /// <summary>Сразу после успешного post-a приёмки.</summary>
public const string SupplyPosted = "SupplyPosted"; public const string SupplyPosted = "SupplyPosted";
/// <summary>Создано персистентное уведомление (центр уведомлений). Фронт по
/// событию перечитывает счётчик непрочитанных и список. Шлётся фоновым
/// джобом уведомлений об отрицательных остатках.</summary>
public const string NotificationCreated = "NotificationCreated";
/// <summary>Когда после движения остаток продукта стал ниже MinStock. /// <summary>Когда после движения остаток продукта стал ниже MinStock.
/// Один event на каждый продукт-склад, в котором факт пересечения /// Один event на каждый продукт-склад, в котором факт пересечения
/// порога. Шлётся только когда было «выше» и стало «ниже» — иначе на /// порога. Шлётся только когда было «выше» и стало «ниже» — иначе на
@ -61,3 +65,10 @@ public sealed record LowStockPayload(
string? StoreName, string? StoreName,
decimal Quantity, decimal Quantity,
decimal MinStock); decimal MinStock);
public sealed record NotificationCreatedPayload(
string Type,
string Title,
Guid StoreId,
int Count,
bool Escalation);

View file

@ -0,0 +1,45 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Notifications;
/// <summary>Персистентное уведомление пользователю (в отличие от эфемерных
/// SignalR-событий). Центр уведомлений в вебе и мобильном API читают именно
/// эту таблицу: список, непрочитанные, пометка «прочитано».
///
/// Создаются фоновым джобом (<c>NegativeStockNotificationJob</c>) — один
/// агрегированный элемент на склад раз в N минут, плюс эскалация по
/// просроченным минусам. Канал доставки (SignalR/Telegram/Web Push) — сверху,
/// сама запись хранится здесь независимо от канала.</summary>
public class Notification : TenantEntity
{
/// <summary>Пользователь-получатель (AppUser.Id). Одно событие на склад
/// разворачивается в N записей — по одной на каждого получателя.</summary>
public Guid RecipientUserId { get; set; }
/// <summary>Тип: <c>negative-stock</c> (агрегат по складу),
/// <c>negative-stock-overdue</c> (эскалация). Фронт по типу выбирает иконку.</summary>
public string Type { get; set; } = null!;
/// <summary>Уровень: <c>warning</c> | <c>info</c>. Для подсветки.</summary>
public string Severity { get; set; } = "warning";
public string Title { get; set; } = null!;
/// <summary>Тело — человекочитаемая сводка (может быть многострочной со
/// списком товаров).</summary>
public string Body { get; set; } = null!;
/// <summary>Куда вести по клику, напр. <c>/inventory/stock?negativeOnly=true&amp;storeId=…</c>.</summary>
public string? Link { get; set; }
/// <summary>Структурированная нагрузка (JSON): storeId, count, список
/// productId — для мобильного приложения и действия «Пересчитать».</summary>
public string? DataJson { get; set; }
/// <summary>Склад, к которому относится уведомление (для дедупликации/
/// группировки агрегата). null для не-складских типов.</summary>
public Guid? StoreId { get; set; }
/// <summary>Когда прочитано. null — непрочитано (учитывается в счётчике).</summary>
public DateTime? ReadAt { get; set; }
}

View file

@ -118,6 +118,28 @@ public class Organization : Entity
/// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary> /// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary>
public int WeightBarcodeCodeDigits { get; set; } = 5; public int WeightBarcodeCodeDigits { get; set; } = 5;
// ─ Уведомления об отрицательных остатках (идея владельца 08.10) ────────
/// <summary>Слать ли уведомления «товары ушли в минус» (центр уведомлений,
/// Telegram, push). Default true. Выключение — только администратор.</summary>
public bool NegStockNotifyEnabled { get; set; } = true;
/// <summary>Агрегация: не чаще одного уведомления на склад раз в N минут.
/// Default 15. Защита от спама при серии продаж в минус.</summary>
public int NegStockNotifyIntervalMinutes { get; set; } = 15;
/// <summary>Тихие часы — час начала (0–23, время Asia/Almaty, UTC+5).
/// В интервале [From,To) уведомления копятся, но не шлются. null — тихих
/// часов нет. Если From==To — считается, что тихих часов нет.</summary>
public int? NegStockQuietFromHour { get; set; }
/// <summary>Тихие часы — час конца (0–23). Интервал может переходить через
/// полночь (напр. From=22, To=8).</summary>
public int? NegStockQuietToHour { get; set; }
/// <summary>Эскалация: если минус не закрыт за N часов — повторное
/// уведомление владельцу/админам + блок «Просроченные минусы». Default 24.</summary>
public int NegStockEscalateHours { get; set; } = 24;
// ─ Sprint 11: ОФД (фискализация чеков) ──────────────────────────── // ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant: // Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret // у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret

View file

@ -77,6 +77,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>(); public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
public DbSet<foodmarket.Domain.Organizations.DeviceToken> DeviceTokens => Set<foodmarket.Domain.Organizations.DeviceToken>(); public DbSet<foodmarket.Domain.Organizations.DeviceToken> DeviceTokens => Set<foodmarket.Domain.Organizations.DeviceToken>();
public DbSet<foodmarket.Domain.Notifications.Notification> Notifications => Set<foodmarket.Domain.Notifications.Notification>();
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>(); public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>(); public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
@ -207,6 +208,20 @@ protected override void OnModelCreating(ModelBuilder builder)
b.HasIndex(x => x.FinishedAt); b.HasIndex(x => x.FinishedAt);
}); });
builder.Entity<foodmarket.Domain.Notifications.Notification>(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.ConfigureCatalog();
builder.ConfigureInventory(); builder.ConfigureInventory();
builder.ConfigurePurchases(); builder.ConfigurePurchases();

View file

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase41 — персистентные уведомления (<c>notifications</c>) + настройки
/// организации для уведомлений об отрицательных остатках (агрегация/тихие часы/
/// эскалация). Идемпотентно (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS).</summary>
[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"";
");
}
}
}

View file

@ -1,99 +1,94 @@
/** /**
* Sprint 18: In-app notification center. * Центр уведомлений (колокольчик в сайдбаре).
* *
* Иконка-колокольчик в topbar (внутри AppLayout sidebar footer'a). Лоадит * Два источника:
* последние 30 SignalR-событий: SalePosted, SupplyPosted, LowStock. Хранит * 1. ПЕРСИСТЕНТНЫЕ уведомления из `/api/notifications` — главный фид. Сюда
* их в локальном state (без persist в БД — events ephemeral, при reload * фоновый джоб кладёт агрегат «N товаров ушли в минус» на склад и эскалацию
* пропадают, что соответствует UX «активная сессия»). * просроченных минусов. Непрочитанные считаются на сервере; открытие
* колокольчика помечает все прочитанными (read-all).
* 2. ЭФЕМЕРНЫЕ live-события (SalePosted/SupplyPosted) из SignalR — «что
* произошло, пока вы смотрели»; не персистятся, при reload пропадают.
* *
* Counter показывает кол-во непрочитанных. Клик по колокольчику открывает * SignalR-событие NotificationCreated инвалидирует фид (живой колокольчик без
* popover; открытие сбрасывает непрочитанный счётчик. Каждое событие — * перезагрузки) и счётчик минусов.
* clickable: ведёт на соответствующий документ.
*
* Принципиально НЕ персистится: если юзеру нужна история — есть
* `/audit-log` и листинги (`/sales/retail`, `/purchases/supplies`).
* Центр уведомлений — это «что произошло, пока вы тут смотрели».
*/ */
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Bell, ShoppingCart, TruckIcon, AlertTriangle, X } from 'lucide-react' import { useQueryClient } from '@tanstack/react-query'
import { useNotificationsHub, type SalePostedPayload, type SupplyPostedPayload, type LowStockPayload } from '@/lib/useNotificationsHub' 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 { useFormatCurrency } from '@/lib/useFormatCurrency'
import { api } from '@/lib/api'
type Notification = type Live =
| { kind: 'sale'; id: string; at: string; payload: SalePostedPayload; read: boolean } | { kind: 'sale'; id: string; at: string; payload: SalePostedPayload }
| { kind: 'supply'; id: string; at: string; payload: SupplyPostedPayload; read: boolean } | { kind: 'supply'; id: string; at: string; payload: SupplyPostedPayload }
| { kind: 'low-stock'; id: string; at: string; payload: LowStockPayload; read: boolean }
const MAX_NOTIFICATIONS = 30 const MAX_LIVE = 20
export function NotificationCenter() { export function NotificationCenter() {
const [items, setItems] = useState<Notification[]>([]) const [live, setLive] = useState<Live[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null) const popoverRef = useRef<HTMLDivElement>(null)
const fmtMoney = useFormatCurrency() const fmtMoney = useFormatCurrency()
const qc = useQueryClient()
const navigate = useNavigate()
const [recounting, setRecounting] = useState<string | null>(null)
const { data } = useNotifications()
const markAllRead = useMarkAllNotificationsRead()
const persisted = data?.items ?? []
const serverUnread = data?.unread ?? 0
// Подписываемся на 3 типа событий через существующий useNotificationsHub.
// Каждое событие prepend'ится в ленту с уникальным id (на случай дубликатов
// от reconnect'ов используем kind+payloadId; при коллизии второй просто
// не добавится).
useNotificationsHub({ useNotificationsHub({
onSalePosted: (p) => { onSalePosted: (p) => setLive((prev) => prependLive(prev, { kind: 'sale', id: `sale-${p.saleId}`, at: p.postedAt, payload: p })),
setItems((prev) => prepend(prev, { onSupplyPosted: (p) => setLive((prev) => prependLive(prev, { kind: 'supply', id: `supply-${p.supplyId}`, at: p.postedAt, payload: p })),
kind: 'sale', id: `sale-${p.saleId}`, at: p.postedAt, payload: p, read: false, // Фоновый джоб создал персистентное уведомление → обновляем фид и счётчик минусов.
})) onNotificationCreated: () => {
}, qc.invalidateQueries({ queryKey: ['/api/notifications'] })
onSupplyPosted: (p) => { qc.invalidateQueries({ queryKey: ['/api/negative-stock/count'] })
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,
}))
}, },
}) })
// Click-outside закрывает popover (но клики внутри ссылок — наоборот
// должны его закрыть после навигации, поэтому стрелки на ссылках вызывают setOpen(false)).
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) setOpen(false)
setOpen(false)
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
} }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onClick) document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => { return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey) }
document.removeEventListener('mousedown', onClick)
document.removeEventListener('keydown', onKey)
}
}, [open]) }, [open])
const unreadCount = items.filter((i) => !i.read).length const unreadCount = serverUnread + live.length
function toggleOpen() { function toggleOpen() {
setOpen((x) => { setOpen((x) => {
const next = !x const next = !x
// При открытии — помечаем все как прочитанные. if (next && serverUnread > 0) markAllRead.mutate()
if (next) setItems((prev) => prev.map((i) => ({ ...i, read: true }))) if (next) setLive([]) // live-события «просмотрены»
return next return next
}) })
} }
function clear() { async function recount(n: NotificationRow) {
setItems([]) 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) 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 ( return (
<div className="relative inline-block" ref={popoverRef}> <div className="relative inline-block" ref={popoverRef}>
@ -107,55 +102,33 @@ export function NotificationCenter() {
> >
<Bell className="w-4 h-4" aria-hidden="true" /> <Bell className="w-4 h-4" aria-hidden="true" />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 bg-red-600 text-white rounded-full text-[10px] font-medium flex items-center justify-center" aria-hidden="true">
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 bg-red-600 text-white rounded-full text-[10px] font-medium flex items-center justify-center"
aria-hidden="true"
>
{unreadCount > 9 ? '9+' : unreadCount} {unreadCount > 9 ? '9+' : unreadCount}
</span> </span>
)} )}
</button> </button>
{open && ( {open && (
<div <div role="dialog" aria-label="Уведомления" className="absolute right-0 bottom-full mb-2 w-80 max-w-[90vw] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg shadow-xl z-50">
role="dialog"
aria-label="Уведомления"
className="absolute right-0 bottom-full mb-2 w-80 max-w-[90vw] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg shadow-xl z-50"
>
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-200 dark:border-slate-800"> <div className="flex items-center justify-between px-3 py-2 border-b border-slate-200 dark:border-slate-800">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Уведомления</h3> <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Уведомления</h3>
<div className="flex items-center gap-2"> <button type="button" onClick={() => setOpen(false)} aria-label="Закрыть" className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-100">
{items.length > 0 && (
<button
type="button"
onClick={clear}
className="text-[11px] text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-100"
>
Очистить
</button>
)}
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Закрыть"
className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-100"
>
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
</div>
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{items.length === 0 ? ( {isEmpty ? (
<div className="px-3 py-8 text-center text-sm text-slate-500 dark:text-slate-400"> <div className="px-3 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Пока ничего нет.<br /> Пока ничего нет.<br />
<span className="text-xs">События появятся при проведении документов.</span> <span className="text-xs">Здесь появятся предупреждения о товарах в минусе.</span>
</div> </div>
) : ( ) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800"> <ul className="divide-y divide-slate-100 dark:divide-slate-800">
{items.map((n) => ( {persisted.map((n) => (
<li key={n.id}> <li key={n.id}>{renderPersisted(n, recounting === n.id, () => recount(n), () => setOpen(false))}</li>
{renderItem(n, fmtMoney, () => setOpen(false))} ))}
</li> {live.map((n) => (
<li key={n.id}>{renderLive(n, fmtMoney, () => setOpen(false))}</li>
))} ))}
</ul> </ul>
)} )}
@ -166,77 +139,64 @@ export function NotificationCenter() {
) )
} }
function prepend(prev: Notification[], n: Notification): Notification[] { function prependLive(prev: Live[], n: Live): Live[] {
// Уникализация по id — если событие уже есть (reconnect-replay), пропускаем.
if (prev.some((p) => p.id === n.id)) return prev 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( function renderPersisted(n: NotificationRow, busy: boolean, onRecount: () => void, closeMenu: () => void) {
n: Notification, const ts = new Date(n.createdAt).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
fmtMoney: ReturnType<typeof useFormatCurrency>, const overdue = n.type === 'negative-stock-overdue'
closeMenu: () => void, const canRecount = (n.type === 'negative-stock' || overdue) && !!n.storeId
) { return (
<div className={`px-3 py-2 ${n.read ? '' : 'bg-amber-50/60 dark:bg-amber-950/20'}`}>
<div className="flex items-start gap-2">
<AlertTriangle className={`w-4 h-4 mt-0.5 shrink-0 ${overdue ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400'}`} aria-hidden="true" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-slate-100">{n.title}</div>
<div className="text-[11px] text-slate-600 dark:text-slate-400 whitespace-pre-line mt-0.5">{n.body}</div>
<div className="text-[11px] text-slate-400 mt-1">{ts}</div>
<div className="flex items-center gap-3 mt-1.5">
{canRecount && (
<button type="button" onClick={onRecount} disabled={busy} className="inline-flex items-center gap-1 px-2 py-1 rounded bg-amber-600 text-white text-[11px] font-semibold hover:bg-amber-700 disabled:opacity-60">
{busy ? <Loader2 className="w-3 h-3 animate-spin" /> : null}
Пересчитать
</button>
)}
{n.link && (
<Link to={n.link} onClick={closeMenu} className="text-[11px] text-amber-800 dark:text-amber-300 underline hover:text-amber-900">
Список
</Link>
)}
</div>
</div>
</div>
</div>
)
}
function renderLive(n: Live, fmtMoney: ReturnType<typeof useFormatCurrency>, closeMenu: () => void) {
const ts = new Date(n.at).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }) const ts = new Date(n.at).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' })
if (n.kind === 'sale') { if (n.kind === 'sale') {
return ( return (
<Link <Link to={`/sales/retail/${n.payload.saleId}`} onClick={closeMenu} className="block px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/50">
to={`/sales/retail/${n.payload.saleId}`}
onClick={closeMenu}
className="block px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<ShoppingCart className="w-4 h-4 text-emerald-600 dark:text-emerald-400 mt-0.5 shrink-0" aria-hidden="true" /> <ShoppingCart className="w-4 h-4 text-emerald-600 dark:text-emerald-400 mt-0.5 shrink-0" aria-hidden="true" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-slate-900 dark:text-slate-100 truncate"> <div className="text-sm text-slate-900 dark:text-slate-100 truncate">Чек {n.payload.number} — {fmtMoney(n.payload.total, { compact: true })}</div>
Чек {n.payload.number} — {fmtMoney(n.payload.total, { compact: true })} <div className="text-[11px] text-slate-500 dark:text-slate-400">{n.payload.cashierName ?? 'Кассир'} · {ts}</div>
</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
{n.payload.cashierName ?? 'Кассир'} · {ts}
</div>
</div> </div>
</div> </div>
</Link> </Link>
) )
} }
if (n.kind === 'supply') {
return ( return (
<Link <Link to={`/purchases/supplies/${n.payload.supplyId}`} onClick={closeMenu} className="block px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/50">
to={`/purchases/supplies/${n.payload.supplyId}`}
onClick={closeMenu}
className="block px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<TruckIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" aria-hidden="true" /> <TruckIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" aria-hidden="true" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-slate-900 dark:text-slate-100 truncate"> <div className="text-sm text-slate-900 dark:text-slate-100 truncate">Приёмка {n.payload.number} — {fmtMoney(n.payload.total, { compact: true })}</div>
Приёмка {n.payload.number} — {fmtMoney(n.payload.total, { compact: true })} <div className="text-[11px] text-slate-500 dark:text-slate-400">{n.payload.supplierName ?? 'Поставщик'} · {ts}</div>
</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
{n.payload.supplierName ?? 'Поставщик'} · {ts}
</div>
</div>
</div>
</Link>
)
}
// low-stock
return (
<Link
to={`/inventory/stock?productId=${n.payload.productId}`}
onClick={closeMenu}
className="block px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" aria-hidden="true" />
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-900 dark:text-slate-100 truncate">
Низкий остаток: {n.payload.productName}
</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
{n.payload.quantity} / {n.payload.minStock}
{n.payload.storeName ? ` · ${n.payload.storeName}` : ''} · {ts}
</div>
</div> </div>
</div> </div>
</Link> </Link>

View file

@ -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<string | null>(null)
const overdue = (data ?? []).filter((a) => a.overdue)
if (overdue.length === 0) return null
const byStore = new Map<string, { storeName: string; items: typeof overdue }>()
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 (
<div className="bg-red-50 dark:bg-red-950/30 border border-red-300 dark:border-red-800 rounded-xl p-4 mb-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" aria-hidden="true" />
<h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
Просроченные минусы — срочно нужна инвентаризация
</h3>
</div>
<div className="space-y-2">
{[...byStore.entries()].map(([storeId, { storeName, items }]) => (
<div key={storeId} className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-red-900 dark:text-red-200">{storeName}:</span>
<span className="text-red-700 dark:text-red-300/90 truncate">
{items.slice(0, 5).map((a) => `${a.productName} (${a.quantity})`).join(', ')}
{items.length > 5 ? ` …и ещё ${items.length - 5}` : ''}
</span>
<button
type="button"
onClick={() => recount(storeId)}
disabled={busy === storeId}
className="ml-auto inline-flex items-center gap-1.5 px-3 py-1 rounded-md bg-red-600 text-white text-xs font-semibold hover:bg-red-700 disabled:opacity-60"
>
{busy === storeId ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
Пересчитать
</button>
</div>
))}
</div>
</div>
)
}

View file

@ -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<NotificationsList>({
queryKey: ['/api/notifications', take],
queryFn: async () => (await api.get<NotificationsList>(`/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'] }),
})
}

View file

@ -49,10 +49,19 @@ export interface LowStockPayload {
minStock: number minStock: number
} }
export interface NotificationCreatedPayload {
type: string
title: string
storeId: string
count: number
escalation: boolean
}
export interface NotificationsHandlers { export interface NotificationsHandlers {
onSalePosted?: (p: SalePostedPayload) => void onSalePosted?: (p: SalePostedPayload) => void
onSupplyPosted?: (p: SupplyPostedPayload) => void onSupplyPosted?: (p: SupplyPostedPayload) => void
onLowStock?: (p: LowStockPayload) => void onLowStock?: (p: LowStockPayload) => void
onNotificationCreated?: (p: NotificationCreatedPayload) => void
} }
export function useNotificationsHub(handlers: NotificationsHandlers) { export function useNotificationsHub(handlers: NotificationsHandlers) {
@ -75,6 +84,7 @@ export function useNotificationsHub(handlers: NotificationsHandlers) {
if (handlers.onSalePosted) conn.on('SalePosted', handlers.onSalePosted) if (handlers.onSalePosted) conn.on('SalePosted', handlers.onSalePosted)
if (handlers.onSupplyPosted) conn.on('SupplyPosted', handlers.onSupplyPosted) if (handlers.onSupplyPosted) conn.on('SupplyPosted', handlers.onSupplyPosted)
if (handlers.onLowStock) conn.on('LowStock', handlers.onLowStock) if (handlers.onLowStock) conn.on('LowStock', handlers.onLowStock)
if (handlers.onNotificationCreated) conn.on('NotificationCreated', handlers.onNotificationCreated)
conn.onreconnected(() => setIsConnected(true)) conn.onreconnected(() => setIsConnected(true))
conn.onreconnecting(() => setIsConnected(false)) conn.onreconnecting(() => setIsConnected(false))
conn.onclose(() => setIsConnected(false)) conn.onclose(() => setIsConnected(false))

View file

@ -18,6 +18,12 @@ export interface OrgSettings {
showReferencePriceOnProduct: boolean showReferencePriceOnProduct: boolean
showCountryOfOriginOnProduct: boolean showCountryOfOriginOnProduct: boolean
showDescriptionOnProduct: boolean showDescriptionOnProduct: boolean
// Уведомления об отрицательных остатках.
negStockNotifyEnabled: boolean
negStockNotifyIntervalMinutes: number
negStockQuietFromHour: number | null
negStockQuietToHour: number | null
negStockEscalateHours: number
} }
export function useOrgSettings() { export function useOrgSettings() {

View file

@ -3,6 +3,7 @@ import { lazy, Suspense, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff, CalendarDays } from 'lucide-react' import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff, CalendarDays } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { OverdueNegativeStockBlock } from '@/components/OverdueNegativeStockBlock'
import { Skeleton } from '@/components/Skeleton' import { Skeleton } from '@/components/Skeleton'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
@ -159,6 +160,9 @@ export function DashboardPage() {
)} )}
/> />
{/* Эскалация: товары, висящие в минусе дольше порога (item 11). */}
<OverdueNegativeStockBlock />
{/* KPI блок продажи: today / week / month + prev-month сравнение */} {/* KPI блок продажи: today / week / month + prev-month сравнение */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard <KpiCard

View file

@ -58,6 +58,11 @@ export function OrganizationSettingsPage() {
showReferencePriceOnProduct: form.showReferencePriceOnProduct, showReferencePriceOnProduct: form.showReferencePriceOnProduct,
showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct, showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct,
showDescriptionOnProduct: form.showDescriptionOnProduct, showDescriptionOnProduct: form.showDescriptionOnProduct,
negStockNotifyEnabled: form.negStockNotifyEnabled,
negStockNotifyIntervalMinutes: form.negStockNotifyIntervalMinutes,
negStockQuietFromHour: form.negStockQuietFromHour,
negStockQuietToHour: form.negStockQuietToHour,
negStockEscalateHours: form.negStockEscalateHours,
} }
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
}, },
@ -196,6 +201,43 @@ export function OrganizationSettingsPage() {
По умолчанию выключено — описания захламляют карточку. Включай если ведёшь По умолчанию выключено — описания захламляют карточку. Включай если ведёшь
подробные тексты на товарах. подробные тексты на товарах.
</p> </p>
<div className="pt-4 mt-2 border-t border-slate-200 dark:border-slate-800">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-1">
Уведомления об отрицательных остатках
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
Продажи/списания в минус разрешены, но система предупреждает и требует
инвентаризации. Получатели — администраторы и кладовщики.
</p>
<Checkbox
label="Слать уведомления о товарах в минусе"
checked={form.negStockNotifyEnabled}
onChange={(v) => setForm({ ...form, negStockNotifyEnabled: v })}
/>
{form.negStockNotifyEnabled && (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Не чаще одного уведомления на склад раз в (минут)">
<TextInput type="number" min={1} max={1440} value={String(form.negStockNotifyIntervalMinutes)}
onChange={(e) => setForm({ ...form, negStockNotifyIntervalMinutes: Number(e.target.value) || 15 })} />
</Field>
<Field label="Эскалация, если минус не закрыт за (часов)">
<TextInput type="number" min={1} max={720} value={String(form.negStockEscalateHours)}
onChange={(e) => setForm({ ...form, negStockEscalateHours: Number(e.target.value) || 24 })} />
</Field>
<Field label="Тихие часы с (час, 0–23; пусто — выкл.)">
<TextInput type="number" min={0} max={23}
value={form.negStockQuietFromHour == null ? '' : String(form.negStockQuietFromHour)}
onChange={(e) => setForm({ ...form, negStockQuietFromHour: e.target.value === '' ? null : Number(e.target.value) })} />
</Field>
<Field label="Тихие часы до (час, 0–23)">
<TextInput type="number" min={0} max={23}
value={form.negStockQuietToHour == null ? '' : String(form.negStockQuietToHour)}
onChange={(e) => setForm({ ...form, negStockQuietToHour: e.target.value === '' ? null : Number(e.target.value) })} />
</Field>
</div>
)}
</div>
</section> </section>
<div className="mt-4 flex gap-3 items-center"> <div className="mt-4 flex gap-3 items-center">

View file

@ -1,7 +1,9 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using FluentAssertions; using FluentAssertions;
using foodmarket.Api.Background;
using foodmarket.IntegrationTests.Support; using foodmarket.IntegrationTests.Support;
using Microsoft.Extensions.DependencyInjection;
using Xunit; using Xunit;
namespace foodmarket.IntegrationTests; namespace foodmarket.IntegrationTests;
@ -104,4 +106,57 @@ public async Task Negative_stock_report_filter_returns_only_negatives()
items.Should().NotBeEmpty(); items.Should().NotBeEmpty();
items.Should().OnlyContain(x => x.GetProperty("quantity").GetDecimal() < 0); 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<JsonElement>()).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<NegativeStockNotificationJob>();
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<NegativeStockNotificationJob>();
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);
}
} }

View file

@ -0,0 +1,46 @@
using System;
using foodmarket.Api.Background;
using FluentAssertions;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Тихие часы агрегированной рассылки уведомлений об отрицательных
/// остатках. Время считается в зоне Asia/Almaty (UTC+5). Интервал может
/// переходить через полночь; null/равные границы = тихих часов нет.</summary>
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();
}
}

View file

@ -0,0 +1,21 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using foodmarket.Application.Inventory;
namespace foodmarket.UnitTests;
/// <summary>No-op реализация <see cref="INegativeStockAlertService"/> для юнит-тестов
/// StockService, которым неважны предупреждения об отрицательных остатках (они
/// проверяют только арифметику остатков). Поведение алертов покрыто отдельными
/// интеграционными тестами (NegativeStockTests).</summary>
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;
}

View file

@ -59,7 +59,7 @@ public async Task Sum_of_movements_equals_stock_quantity(int seed, int count)
} }
using var db = sqlite.Create(tenant); 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++) for (var i = 0; i < movements.Count; i++)
{ {
var qty = movements[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); 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 svc.ApplyMovementsAsync(drafts);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -147,7 +147,7 @@ public async Task Two_products_in_same_store_dont_interfere()
var p2 = Guid.NewGuid(); var p2 = Guid.NewGuid();
using var db = sqlite.Create(tenant); using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant); var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
// Каждый ApplyMovementAsync + SaveChangesAsync — отдельная «проводка», // Каждый ApplyMovementAsync + SaveChangesAsync — отдельная «проводка»,
// как делают контроллеры. Без SaveChanges между вызовами на одном // как делают контроллеры. Без SaveChanges между вызовами на одном
// ProductId второй вызов не увидит pending-Stock и попытается // ProductId второй вызов не увидит pending-Stock и попытается

View file

@ -28,7 +28,7 @@ public async Task Creates_stock_and_movement_on_first_application()
decimal returned; decimal returned;
using (var db = sqlite.Create(tenant)) 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(); await db.SaveChangesAsync();
} }
@ -53,7 +53,7 @@ public async Task Second_application_increments_existing_stock()
var store = Guid.NewGuid(); var store = Guid.NewGuid();
using var db = sqlite.Create(tenant); 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 svc.ApplyMovementAsync(Draft(product, store, 10m));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -74,7 +74,7 @@ public async Task Negative_movement_decrements_stock()
var store = Guid.NewGuid(); var store = Guid.NewGuid();
using var db = sqlite.Create(tenant); 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 svc.ApplyMovementAsync(Draft(product, store, 10m));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -90,7 +90,7 @@ public async Task Throws_when_tenant_not_set()
using var sqlite = new SqliteDb(foreignKeys: false); using var sqlite = new SqliteDb(foreignKeys: false);
var tenant = new FakeTenantContext { OrganizationId = null }; var tenant = new FakeTenantContext { OrganizationId = null };
using var db = sqlite.Create(tenant); using var db = sqlite.Create(tenant);
var svc = new StockService(db, tenant); var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
await FluentActions await FluentActions
.Awaiting(() => svc.ApplyMovementAsync(Draft(Guid.NewGuid(), Guid.NewGuid(), 1m))) .Awaiting(() => svc.ApplyMovementAsync(Draft(Guid.NewGuid(), Guid.NewGuid(), 1m)))