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>
100 lines
4.1 KiB
C#
100 lines
4.1 KiB
C#
using FluentAssertions;
|
||
using foodmarket.Application.Inventory;
|
||
using foodmarket.Domain.Inventory;
|
||
using foodmarket.Infrastructure.Inventory;
|
||
using foodmarket.UnitTests.Support;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.UnitTests;
|
||
|
||
/// <summary>StockService.ApplyMovement: материализация Stock + запись StockMovement.
|
||
/// FK отключены — тестируем арифметику остатка и факт записи движения, не целостность.</summary>
|
||
public class StockServiceTests
|
||
{
|
||
private static StockMovementDraft Draft(Guid product, Guid store, decimal qty) =>
|
||
new(product, store, qty, MovementType.Supply, "supply",
|
||
DocumentId: Guid.NewGuid(), DocumentNumber: "DOC-1", UnitCost: 100m, OccurredAt: DateTime.UtcNow);
|
||
|
||
[Fact]
|
||
public async Task Creates_stock_and_movement_on_first_application()
|
||
{
|
||
using var sqlite = new SqliteDb(foreignKeys: false);
|
||
var org = Guid.NewGuid();
|
||
var tenant = new FakeTenantContext { OrganizationId = org };
|
||
var product = Guid.NewGuid();
|
||
var store = Guid.NewGuid();
|
||
|
||
decimal returned;
|
||
using (var db = sqlite.Create(tenant))
|
||
{
|
||
returned = await new StockService(db, tenant, NoopNegativeStockAlertService.Instance).ApplyMovementAsync(Draft(product, store, 10m));
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
returned.Should().Be(10m);
|
||
using (var db = sqlite.Create(tenant))
|
||
{
|
||
var stock = await db.Stocks.SingleAsync(s => s.ProductId == product && s.StoreId == store);
|
||
stock.Quantity.Should().Be(10m);
|
||
stock.OrganizationId.Should().Be(org);
|
||
(await db.StockMovements.CountAsync()).Should().Be(1);
|
||
}
|
||
}
|
||
|
||
// Каждое проведение документа = свой SaveChanges, поэтому повторное движение
|
||
// по тому же товару находит уже материализованный Stock и инкрементит его.
|
||
[Fact]
|
||
public async Task Second_application_increments_existing_stock()
|
||
{
|
||
using var sqlite = new SqliteDb(foreignKeys: false);
|
||
var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
|
||
var product = Guid.NewGuid();
|
||
var store = Guid.NewGuid();
|
||
|
||
using var db = sqlite.Create(tenant);
|
||
var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
|
||
|
||
await svc.ApplyMovementAsync(Draft(product, store, 10m));
|
||
await db.SaveChangesAsync();
|
||
var afterSecond = await svc.ApplyMovementAsync(Draft(product, store, 5m));
|
||
await db.SaveChangesAsync();
|
||
|
||
afterSecond.Should().Be(15m);
|
||
(await db.Stocks.SingleAsync(s => s.ProductId == product && s.StoreId == store)).Quantity.Should().Be(15m);
|
||
(await db.StockMovements.CountAsync()).Should().Be(2);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Negative_movement_decrements_stock()
|
||
{
|
||
using var sqlite = new SqliteDb(foreignKeys: false);
|
||
var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
|
||
var product = Guid.NewGuid();
|
||
var store = Guid.NewGuid();
|
||
|
||
using var db = sqlite.Create(tenant);
|
||
var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
|
||
|
||
await svc.ApplyMovementAsync(Draft(product, store, 10m));
|
||
await db.SaveChangesAsync();
|
||
var afterSale = await svc.ApplyMovementAsync(Draft(product, store, -3m));
|
||
await db.SaveChangesAsync();
|
||
|
||
afterSale.Should().Be(7m);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Throws_when_tenant_not_set()
|
||
{
|
||
using var sqlite = new SqliteDb(foreignKeys: false);
|
||
var tenant = new FakeTenantContext { OrganizationId = null };
|
||
using var db = sqlite.Create(tenant);
|
||
var svc = new StockService(db, tenant, NoopNegativeStockAlertService.Instance);
|
||
|
||
await FluentActions
|
||
.Awaiting(() => svc.ApplyMovementAsync(Draft(Guid.NewGuid(), Guid.NewGuid(), 1m)))
|
||
.Should().ThrowAsync<InvalidOperationException>();
|
||
}
|
||
}
|