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
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:
parent
242f48ff9f
commit
29a951a6ff
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
224
src/food-market.api/Background/NegativeStockNotificationJob.cs
Normal file
224
src/food-market.api/Background/NegativeStockNotificationJob.cs
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/food-market.api/Controllers/NotificationsController.cs
Normal file
95
src/food-market.api/Controllers/NotificationsController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
45
src/food-market.domain/Notifications/Notification.cs
Normal file
45
src/food-market.domain/Notifications/Notification.cs
Normal 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&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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"";
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/food-market.web/src/lib/useNotifications.ts
Normal file
48
src/food-market.web/src/lib/useNotifications.ts
Normal 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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/food-market.UnitTests/NoopNegativeStockAlertService.cs
Normal file
21
tests/food-market.UnitTests/NoopNegativeStockAlertService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 и попытается
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue