feat(stock): разрешение продажи в минус + предупреждения об отрицательных остатках
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
Решение владельца 08.10: продавать/списывать в минус РАЗРЕШЕНО, но с постоянным предупреждением и требованием провести инвентаризацию. - Снята блокировка «недостаточно остатка» (409 oversell): розница, касса (POS-синк), опт-отгрузка, списание, перемещение, возврат поставщику, отмена приёмки. Деньги (минус счёта) — по-прежнему запрещены (другое правило). - NegativeStockAlert (сущность + миграция Phase39): создаётся/обновляется при уходе остатка < 0 (единая точка — StockService), авто-закрытие при возврате ≥ 0. Частичный уникальный индекс: один открытый алерт на (org, store, product). Причина: тип/номер документа/автор/время. - API /api/negative-stock: список (с подсказкой «возможно, не проведена приёмка П-…»), /count (для баннеров), /recount (черновик инвентаризации по минусам склада). Отчёт «Остатки» — фильтр negativeOnly. - Веб: постоянный баннер «N товаров в минусе — нужна инвентаризация» (сворачивается, крестиком не закрыть) + «Пересчитать эти товары»; в остатках — красная пометка и фильтр «Только минусы». - Тесты: integration 2/2 (минус разрешён → алерт → recount → авто-закрытие; фильтр); full-chain шаг 9/13 переписаны (oversell/отмена приёмки → проведено + предупреждение). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b095329c5a
commit
9ba1edb236
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -97,3 +97,4 @@ src/food-market.public/node_modules/
|
||||||
## POS build artifacts (self-contained сборки кассы — не в git)
|
## POS build artifacts (self-contained сборки кассы — не в git)
|
||||||
deploy/pos-dist/
|
deploy/pos-dist/
|
||||||
tests/**/TestResults/
|
tests/**/TestResults/
|
||||||
|
src/food-market.api/uploads/
|
||||||
|
|
|
||||||
55
docs/negative-stock-alerts-progress.md
Normal file
55
docs/negative-stock-alerts-progress.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Уведомления об отрицательных остатках → инвентаризация
|
||||||
|
|
||||||
|
Идея владельца 08.10.2026: «когда у товара количество становится отрицательным, система и
|
||||||
|
мобильное приложение должны отправлять PUSH о том, что необходимо произвести инвентаризацию
|
||||||
|
по таким товарам, и сразу предоставить список таких товаров».
|
||||||
|
|
||||||
|
Решение владельца 08.10: **разрешать продажу в минус нужно**, но с постоянным предупреждением
|
||||||
|
везде (касса, админка, приложение) и требованием срочно провести инвентаризацию.
|
||||||
|
|
||||||
|
Поправка 09.10: **пункт 12 (предварительная себестоимость) НЕ делаем** — тема отложена;
|
||||||
|
себестоимость продаж в минус оставляем как сейчас, расчёт не трогаем.
|
||||||
|
|
||||||
|
## Сверка с МойСкладом (что есть у них)
|
||||||
|
- **Отчёт «Остатки»** (`report/stock`) с параметром `stockMode`: значения `all`, `positiveOnly`,
|
||||||
|
`negativeOnly`, `underminimum`, `nonEmpty`, `empty`. Т.е. отрицательные остатки — это **только
|
||||||
|
фильтр в отчёте** (`stockMode=negativeOnly`). Отдельного «уведомления» нет.
|
||||||
|
- **Вебхуки** МойСклада — на CRUD-события сущностей (создание/изменение/удаление документов и
|
||||||
|
справочников). Порога «остаток ушёл в минус» среди событий **нет** — вебхук на изменение
|
||||||
|
документа есть, но «стало отрицательно» как события нет.
|
||||||
|
- Минус **разрешён** настройкой («разрешить отрицательные остатки»); push/требования пересчёта нет.
|
||||||
|
|
||||||
|
**Вывод:** у МойСклада — пассивный отчёт-фильтр. Мы делаем **лучше**: проактивные предупреждения
|
||||||
|
(событие при уходе в минус), постоянные баннеры/индикаторы в кассе/админке/приложении, push
|
||||||
|
(web/telegram, APNs-заглушка), подсказка причины, и действие «Пересчитать» (авто-черновик
|
||||||
|
инвентаризации по минусовым товарам), с авто-закрытием при возврате остатка ≥ 0 и эскалацией.
|
||||||
|
(Источник по МойСкладу: dev.moysklad.ru/doc/api/remap/1.2 report/stock stockMode; webhooks — на сущности.)
|
||||||
|
|
||||||
|
## План / статус
|
||||||
|
0. [ ] Сверка с МойСкладом (отчёт/вебхуки/разрешение минуса).
|
||||||
|
1. [ ] Событие при проведении документа → остаток < 0: создать/обновить предупреждение
|
||||||
|
(товар, склад, остаток, документ-причина). Остаток ≥ 0 → закрыть автоматически.
|
||||||
|
2. [ ] Подсказка причины: есть непроведённая приёмка/черновик → «возможно, не проведена приёмка П-…».
|
||||||
|
3. [ ] Без спама: агрегация (1 уведомление/склад/N мин, по умолч. 15), тихие часы, получатели
|
||||||
|
Администратор + Кладовщики склада, вкл/выкл в настройках орг и профиле.
|
||||||
|
4. [ ] Действие «Пересчитать эти товары»: черновик инвентаризации по складу с этими товарами.
|
||||||
|
5. [ ] Каналы: веб (колокольчик + центр уведомлений + Web Push/VAPID), Telegram (бот),
|
||||||
|
мобильное API (GET /api/notifications, read, создать инвентаризацию), отчёт «Отрицательные».
|
||||||
|
APNs — только заглушка (device tokens + IPushSender), без платного Apple Dev.
|
||||||
|
6. [ ] Тесты unit/integration/e2e; деплой; скриншоты.
|
||||||
|
7. [ ] Снять блокировку продажи в минус: касса/POS-синк/офлайн, розница веб, опт, списание,
|
||||||
|
перемещение — проведение в минус РАЗРЕШЕНО (убрать 409 oversell; деньги — счёт в минус запрещён).
|
||||||
|
8. [ ] Касса: предупреждение в строке («на остатке 2 шт — уйдёт в минус»), жёлтый индикатор в шапке
|
||||||
|
«N товаров в минусе», офлайн по локальным остаткам.
|
||||||
|
9. [ ] Админка: постоянный баннер «N товаров в минусе — проведите инвентаризацию» + «Пересчитать»;
|
||||||
|
красная пометка в списках товаров/остатков.
|
||||||
|
10. [ ] Мобильное API/iOS: счётчик + список в /api/me или /api/notifications.
|
||||||
|
11. [ ] Эскалация: минус не закрыт за N часов (по умолч. 24) → повторное уведомление владельцу/админу;
|
||||||
|
на главной — «Просроченные минусы».
|
||||||
|
12. [~] ОТЛОЖЕНО (поправка владельца) — предварительная себестоимость НЕ делаем.
|
||||||
|
|
||||||
|
## Что НЕ трогаем
|
||||||
|
Прод, shared-postgres, global.json, gateway nginx, sklad. Расчёт себестоимости (п.12 отложен).
|
||||||
|
|
||||||
|
## Журнал
|
||||||
|
- 09.10 — старт: прогресс-док, сверка с МойСкладом, обследование кода.
|
||||||
|
|
@ -246,38 +246,8 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
if (loss.Status == LossStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
if (loss.Status == LossStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
||||||
if (loss.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
if (loss.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||||||
|
|
||||||
// Защита от ухода stock в минус.
|
// Списание в минус РАЗРЕШЕНО (решение владельца 08.10): остаток может уйти
|
||||||
var byProduct = loss.Lines.GroupBy(l => l.ProductId)
|
// ниже нуля; при проведении создаётся предупреждение об отрицательном остатке.
|
||||||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
|
|
||||||
var productIds = byProduct.Select(x => x.ProductId).ToList();
|
|
||||||
var stocks = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == loss.StoreId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
var conflicts = new List<object>();
|
|
||||||
foreach (var r in byProduct)
|
|
||||||
{
|
|
||||||
stocks.TryGetValue(r.ProductId, out var available);
|
|
||||||
if (available < r.Quantity)
|
|
||||||
{
|
|
||||||
var name = await _db.Products.Where(p => p.Id == r.ProductId)
|
|
||||||
.Select(p => p.Name).FirstOrDefaultAsync(ct);
|
|
||||||
conflicts.Add(new
|
|
||||||
{
|
|
||||||
productId = r.ProductId,
|
|
||||||
productName = name,
|
|
||||||
writeOffQty = r.Quantity,
|
|
||||||
available,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (conflicts.Count > 0)
|
|
||||||
{
|
|
||||||
return Conflict(new
|
|
||||||
{
|
|
||||||
error = "Нельзя списать больше, чем есть в наличии.",
|
|
||||||
lines = conflicts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(
|
await using var tx = await _db.Database.BeginTransactionAsync(
|
||||||
System.Data.IsolationLevel.Serializable, ct);
|
System.Data.IsolationLevel.Serializable, ct);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Inventory;
|
||||||
|
|
||||||
|
/// <summary>Предупреждения об отрицательных остатках: список открытых, счётчик для
|
||||||
|
/// баннеров/индикаторов (касса/админка/приложение) и действие «Пересчитать эти
|
||||||
|
/// товары» (черновик инвентаризации по складу ровно с минусовыми товарами).
|
||||||
|
///
|
||||||
|
/// Остатки в минус разрешены (решение владельца 08.10), но требуют срочной
|
||||||
|
/// инвентаризации — отсюда эти эндпоинты и постоянные индикаторы.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/negative-stock")]
|
||||||
|
public class NegativeStockController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public NegativeStockController(AppDbContext db, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AlertRow(
|
||||||
|
Guid id, Guid storeId, string storeName, Guid productId, string productName,
|
||||||
|
decimal quantity, string? causeDocType, string? causeDocNumber, Guid? causeUserId,
|
||||||
|
DateTime? causeAt, DateTime firstDetectedAt, DateTime lastUpdatedAt,
|
||||||
|
bool overdue, string? hint);
|
||||||
|
|
||||||
|
public record StoreCount(Guid storeId, string storeName, int count);
|
||||||
|
public record CountResult(int total, IReadOnlyList<StoreCount> byStore);
|
||||||
|
|
||||||
|
/// <summary>Открытые предупреждения (ResolvedAt IS NULL). Фильтр по складу опц.
|
||||||
|
/// Для каждого — подсказка причины, если есть непроведённая приёмка по товару.</summary>
|
||||||
|
[HttpGet, RequiresPermission("StocksView")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<AlertRow>>> List(
|
||||||
|
[FromQuery] Guid? storeId, [FromQuery] int overdueHours = 24, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var q = _db.NegativeStockAlerts.Where(a => a.ResolvedAt == null);
|
||||||
|
if (storeId is { } sid) q = q.Where(a => a.StoreId == sid);
|
||||||
|
|
||||||
|
var raw = await (from a in q
|
||||||
|
join p in _db.Products on a.ProductId equals p.Id
|
||||||
|
join s in _db.Stores on a.StoreId equals s.Id
|
||||||
|
orderby a.FirstDetectedAt
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
a.Id, a.StoreId, StoreName = s.Name, a.ProductId, ProductName = p.Name,
|
||||||
|
a.Quantity, a.CauseDocType, a.CauseDocNumber, a.CauseUserId, a.CauseAt,
|
||||||
|
a.FirstDetectedAt, a.LastUpdatedAt,
|
||||||
|
}).ToListAsync(ct);
|
||||||
|
|
||||||
|
// Подсказка причины: по каждому товару ищем черновик приёмки с этим товаром.
|
||||||
|
var productIds = raw.Select(x => x.ProductId).Distinct().ToList();
|
||||||
|
var draftSupplyByProduct = await (from sl in _db.SupplyLines
|
||||||
|
join sup in _db.Supplies on sl.SupplyId equals sup.Id
|
||||||
|
where sup.Status == Domain.Purchases.SupplyStatus.Draft
|
||||||
|
&& productIds.Contains(sl.ProductId)
|
||||||
|
select new { sl.ProductId, sup.Number, SupplierName = sup.Supplier.Name })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var hintByProduct = draftSupplyByProduct
|
||||||
|
.GroupBy(x => x.ProductId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var rows = raw.Select(x =>
|
||||||
|
{
|
||||||
|
string? hint = null;
|
||||||
|
if (hintByProduct.TryGetValue(x.ProductId, out var ds))
|
||||||
|
hint = $"Возможно, не проведена приёмка {ds.Number} от {ds.SupplierName}";
|
||||||
|
var overdue = (now - x.FirstDetectedAt).TotalHours >= overdueHours;
|
||||||
|
return new AlertRow(x.Id, x.StoreId, x.StoreName, x.ProductId, x.ProductName,
|
||||||
|
x.Quantity, x.CauseDocType, x.CauseDocNumber, x.CauseUserId, x.CauseAt,
|
||||||
|
x.FirstDetectedAt, x.LastUpdatedAt, overdue, hint);
|
||||||
|
}).ToList();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Счётчик открытых минусов (для жёлтого индикатора в кассе/баннера в
|
||||||
|
/// админке/счётчика в приложении), с разбивкой по складам.</summary>
|
||||||
|
[HttpGet("count")]
|
||||||
|
public async Task<ActionResult<CountResult>> Count([FromQuery] Guid? storeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.NegativeStockAlerts.Where(a => a.ResolvedAt == null);
|
||||||
|
if (storeId is { } sid) q = q.Where(a => a.StoreId == sid);
|
||||||
|
var byStore = await (from a in q
|
||||||
|
join s in _db.Stores on a.StoreId equals s.Id
|
||||||
|
group s.Name by new { a.StoreId, s.Name } into g
|
||||||
|
select new StoreCount(g.Key.StoreId, g.Key.Name, g.Count()))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new CountResult(byStore.Sum(x => x.count), byStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RecountInput(Guid storeId);
|
||||||
|
public record RecountResult(Guid inventoryId, string number, int lines);
|
||||||
|
|
||||||
|
/// <summary>«Пересчитать эти товары»: создаёт ЧЕРНОВИК инвентаризации по складу
|
||||||
|
/// ровно с теми товарами, что сейчас в минусе (BookQty = текущий отрицательный
|
||||||
|
/// остаток). Кладовщик вносит факт и проводит — минусы закрываются.</summary>
|
||||||
|
[HttpPost("recount"), RequiresPermission("InventoryEdit")]
|
||||||
|
public async Task<ActionResult<RecountResult>> Recount([FromBody] RecountInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||||
|
var store = await _db.Stores.FirstOrDefaultAsync(s => s.Id == input.storeId, ct);
|
||||||
|
if (store is null) return BadRequest(new { error = "Склад не найден." });
|
||||||
|
|
||||||
|
var negatives = await (from a in _db.NegativeStockAlerts
|
||||||
|
where a.ResolvedAt == null && a.StoreId == input.storeId
|
||||||
|
join p in _db.Products on a.ProductId equals p.Id
|
||||||
|
join st in _db.Stocks on new { a.ProductId, a.StoreId } equals new { st.ProductId, st.StoreId }
|
||||||
|
select new { a.ProductId, Qty = st.Quantity, Cost = p.Cost }).ToListAsync(ct);
|
||||||
|
if (negatives.Count == 0)
|
||||||
|
return Conflict(new { error = "На этом складе нет товаров в минусе." });
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var doc = new InventoryDoc
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Number = await GenerateInventoryNumberAsync(now, ct),
|
||||||
|
Date = now,
|
||||||
|
Status = InventoryStatus.Draft,
|
||||||
|
StoreId = input.storeId,
|
||||||
|
Notes = "Пересчёт товаров в минусе",
|
||||||
|
};
|
||||||
|
int order = 0;
|
||||||
|
foreach (var n in negatives)
|
||||||
|
{
|
||||||
|
doc.Lines.Add(new InventoryLine
|
||||||
|
{
|
||||||
|
OrganizationId = orgId, InventoryDocId = doc.Id, ProductId = n.ProductId,
|
||||||
|
BookQty = n.Qty, // расчётный = текущий (отрицательный)
|
||||||
|
ActualQty = n.Qty, // кладовщик впишет фактический
|
||||||
|
Diff = 0, UnitCost = n.Cost, SortOrder = order++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_db.InventoryDocs.Add(doc);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new RecountResult(doc.Id, doc.Number, doc.Lines.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateInventoryNumberAsync(DateTime date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prefix = $"И-{date.Year}-";
|
||||||
|
var last = await _db.InventoryDocs.Where(i => i.Number.StartsWith(prefix))
|
||||||
|
.OrderByDescending(i => i.Number).Select(i => i.Number).FirstOrDefaultAsync(ct);
|
||||||
|
var seq = 1;
|
||||||
|
if (last is not null && int.TryParse(last[prefix.Length..], out var n)) seq = n + 1;
|
||||||
|
return $"{prefix}{seq:D6}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ public record StockRow(
|
||||||
[FromQuery] Guid? groupId,
|
[FromQuery] Guid? groupId,
|
||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
[FromQuery] bool includeZero = false,
|
[FromQuery] bool includeZero = false,
|
||||||
|
[FromQuery] bool negativeOnly = false,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 50,
|
[FromQuery] int pageSize = 50,
|
||||||
[FromQuery] string? sort = null,
|
[FromQuery] string? sort = null,
|
||||||
|
|
@ -46,7 +47,8 @@ public record StockRow(
|
||||||
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
|
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
|
||||||
q = q.Where(x => groupIds.Contains(x.p.ProductGroupId));
|
q = q.Where(x => groupIds.Contains(x.p.ProductGroupId));
|
||||||
}
|
}
|
||||||
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
if (negativeOnly) q = q.Where(x => x.s.Quantity < 0);
|
||||||
|
else if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
var term = $"%{search.Trim()}%";
|
var term = $"%{search.Trim()}%";
|
||||||
|
|
|
||||||
|
|
@ -245,38 +245,8 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
if (t.Status == TransferStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
if (t.Status == TransferStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
||||||
if (t.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
if (t.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||||||
|
|
||||||
// Проверка: на FromStore хватает товара (нельзя уйти в минус).
|
// Перемещение в минус РАЗРЕШЕНО (решение владельца 08.10): остаток склада-
|
||||||
var byProduct = t.Lines.GroupBy(l => l.ProductId)
|
// отправителя может уйти ниже нуля; при проведении создаётся предупреждение.
|
||||||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
|
|
||||||
var productIds = byProduct.Select(x => x.ProductId).ToList();
|
|
||||||
var stocks = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == t.FromStoreId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
var conflicts = new List<object>();
|
|
||||||
foreach (var r in byProduct)
|
|
||||||
{
|
|
||||||
stocks.TryGetValue(r.ProductId, out var available);
|
|
||||||
if (available < r.Quantity)
|
|
||||||
{
|
|
||||||
var name = await _db.Products.Where(p => p.Id == r.ProductId)
|
|
||||||
.Select(p => p.Name).FirstOrDefaultAsync(ct);
|
|
||||||
conflicts.Add(new
|
|
||||||
{
|
|
||||||
productId = r.ProductId,
|
|
||||||
productName = name,
|
|
||||||
requested = r.Quantity,
|
|
||||||
available,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (conflicts.Count > 0)
|
|
||||||
{
|
|
||||||
return Conflict(new
|
|
||||||
{
|
|
||||||
error = "На складе-отправителе недостаточно товара.",
|
|
||||||
lines = conflicts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Атомарная транзакция: пара движений (Out + In) — либо обе, либо ни одной.
|
// Атомарная транзакция: пара движений (Out + In) — либо обе, либо ни одной.
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(
|
await using var tx = await _db.Database.BeginTransactionAsync(
|
||||||
|
|
|
||||||
|
|
@ -330,25 +330,10 @@ orderby l.SortOrder
|
||||||
if (src.Lines.Count == 0)
|
if (src.Lines.Count == 0)
|
||||||
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
||||||
|
|
||||||
// Возврат покупателю добавляет остаток, поэтому pre-flight не нужен; для
|
// Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку
|
||||||
// продажи проверяем остаток до создания RetailSale, чтобы зря не дёргать БД.
|
// убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата
|
||||||
if (!src.IsReturn)
|
// по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано).
|
||||||
{
|
if (src.IsReturn && src.ReferenceSaleId is Guid refIdCheck)
|
||||||
var byProduct = src.Lines.GroupBy(l => l.ProductId)
|
|
||||||
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
|
|
||||||
var productIds = byProduct.Select(x => x.ProductId).ToList();
|
|
||||||
var stocks = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == storeId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
foreach (var x in byProduct)
|
|
||||||
{
|
|
||||||
stocks.TryGetValue(x.ProductId, out var avail);
|
|
||||||
if (avail < x.Qty)
|
|
||||||
throw new PosSaleRejectedException(
|
|
||||||
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (src.ReferenceSaleId is Guid refIdCheck)
|
|
||||||
{
|
{
|
||||||
// Возврат по чеку: нельзя вернуть больше, чем продано минус уже
|
// Возврат по чеку: нельзя вернуть больше, чем продано минус уже
|
||||||
// возвращено (QtyReturned на исходных строках). Защита от over-return.
|
// возвращено (QtyReturned на исходных строках). Защита от over-return.
|
||||||
|
|
|
||||||
|
|
@ -267,31 +267,8 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
if (r.Status == SupplierReturnStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
if (r.Status == SupplierReturnStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
||||||
if (r.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
if (r.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||||||
|
|
||||||
// Защита от ухода в минус.
|
// Возврат поставщику в минус РАЗРЕШЁН (решение владельца 08.10): остаток
|
||||||
var byProduct = r.Lines.GroupBy(l => l.ProductId)
|
// может уйти ниже нуля; при проведении создаётся предупреждение.
|
||||||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
|
|
||||||
var productIds = byProduct.Select(x => x.ProductId).ToList();
|
|
||||||
var stocks = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == r.StoreId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
var conflicts = new List<object>();
|
|
||||||
foreach (var x in byProduct)
|
|
||||||
{
|
|
||||||
stocks.TryGetValue(x.ProductId, out var available);
|
|
||||||
if (available < x.Quantity)
|
|
||||||
{
|
|
||||||
var name = await _db.Products.Where(p => p.Id == x.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
|
|
||||||
conflicts.Add(new
|
|
||||||
{
|
|
||||||
productId = x.ProductId,
|
|
||||||
productName = name,
|
|
||||||
returnQty = x.Quantity,
|
|
||||||
available,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (conflicts.Count > 0)
|
|
||||||
return Conflict(new { error = "Нельзя вернуть больше, чем есть на складе.", lines = conflicts });
|
|
||||||
|
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(
|
await using var tx = await _db.Database.BeginTransactionAsync(
|
||||||
System.Data.IsolationLevel.Serializable, ct);
|
System.Data.IsolationLevel.Serializable, ct);
|
||||||
|
|
|
||||||
|
|
@ -522,46 +522,9 @@ public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||||||
if (supply is null) return NotFound();
|
if (supply is null) return NotFound();
|
||||||
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
||||||
|
|
||||||
// Защита от ухода stock в минус: суммируем то, что вернём (по строкам
|
// Отмена приёмки в минус РАЗРЕШЕНА (решение владельца 08.10): если часть
|
||||||
// с тем же ProductId — могут быть дубли), сравниваем с текущим Stock.
|
// полученного товара уже продана, остаток уйдёт ниже нуля — это допустимо,
|
||||||
// Если приёмку «расковать», stock уменьшится на line.Quantity. Если
|
// при проведении (reversal) создаётся предупреждение об отрицательном остатке.
|
||||||
// покупатели уже что-то купили из этой партии, остаток станет
|
|
||||||
// отрицательным — это нарушение инварианта учёта.
|
|
||||||
var reverseByProduct = supply.Lines
|
|
||||||
.GroupBy(l => l.ProductId)
|
|
||||||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
|
|
||||||
.ToList();
|
|
||||||
var productIds = reverseByProduct.Select(r => r.ProductId).ToList();
|
|
||||||
var stocks = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == supply.StoreId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
var conflicts = new List<object>();
|
|
||||||
foreach (var r in reverseByProduct)
|
|
||||||
{
|
|
||||||
stocks.TryGetValue(r.ProductId, out var available);
|
|
||||||
if (available < r.Quantity)
|
|
||||||
{
|
|
||||||
var name = await _db.Products
|
|
||||||
.Where(p => p.Id == r.ProductId)
|
|
||||||
.Select(p => p.Name)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
conflicts.Add(new
|
|
||||||
{
|
|
||||||
productId = r.ProductId,
|
|
||||||
productName = name,
|
|
||||||
reverseQty = r.Quantity,
|
|
||||||
available,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (conflicts.Count > 0)
|
|
||||||
{
|
|
||||||
return Conflict(new
|
|
||||||
{
|
|
||||||
error = "Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).",
|
|
||||||
lines = conflicts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reverse: negative movements with same document reference
|
// Reverse: negative movements with same document reference
|
||||||
foreach (var line in supply.Lines)
|
foreach (var line in supply.Lines)
|
||||||
|
|
|
||||||
|
|
@ -230,30 +230,8 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
if (demand.Status == DemandStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
if (demand.Status == DemandStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
||||||
if (demand.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
if (demand.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||||||
|
|
||||||
// Защита от ухода в минус.
|
// Отгрузка в минус РАЗРЕШЕНА (решение владельца 08.10): остаток может уйти
|
||||||
var byProduct = demand.Lines.GroupBy(l => l.ProductId)
|
// ниже нуля, при проведении создаётся предупреждение об отрицательном остатке.
|
||||||
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
|
|
||||||
var productIds = byProduct.Select(x => x.ProductId).ToList();
|
|
||||||
var stocks = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == demand.StoreId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
var conflicts = new List<object>();
|
|
||||||
foreach (var x in byProduct)
|
|
||||||
{
|
|
||||||
stocks.TryGetValue(x.ProductId, out var avail);
|
|
||||||
if (avail < x.Qty)
|
|
||||||
{
|
|
||||||
var name = await _db.Products.Where(p => p.Id == x.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
|
|
||||||
conflicts.Add(new
|
|
||||||
{
|
|
||||||
productId = x.ProductId, productName = name,
|
|
||||||
requested = x.Qty, available = avail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (conflicts.Count > 0)
|
|
||||||
return Conflict(new { error = "Недостаточно остатка для проведения отгрузки.", lines = conflicts });
|
|
||||||
|
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(
|
await using var tx = await _db.Database.BeginTransactionAsync(
|
||||||
System.Data.IsolationLevel.Serializable, ct);
|
System.Data.IsolationLevel.Serializable, ct);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -626,47 +626,10 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(
|
await using var tx = await _db.Database.BeginTransactionAsync(
|
||||||
System.Data.IsolationLevel.Serializable, ct);
|
System.Data.IsolationLevel.Serializable, ct);
|
||||||
|
|
||||||
// Если в одном чеке один и тот же продукт встречается несколько раз,
|
// Продажа в минус РАЗРЕШЕНА (решение владельца 08.10): остаток может уйти
|
||||||
// нужно сравнить с остатком СУММУ всех строк, а не каждую отдельно.
|
// ниже нуля. Блокировки «недостаточно остатка» больше нет — вместо неё при
|
||||||
var requested = sale.Lines
|
// проведении автоматически создаётся предупреждение об отрицательном остатке
|
||||||
.GroupBy(l => l.ProductId)
|
// (StockService → NegativeStockAlert), с требованием провести инвентаризацию.
|
||||||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var productIds = requested.Select(r => r.ProductId).ToList();
|
|
||||||
var stocksByProduct = await _db.Stocks
|
|
||||||
.Where(s => s.StoreId == sale.StoreId && productIds.Contains(s.ProductId))
|
|
||||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
|
||||||
|
|
||||||
var insufficient = new List<object>();
|
|
||||||
foreach (var r in requested)
|
|
||||||
{
|
|
||||||
stocksByProduct.TryGetValue(r.ProductId, out var available);
|
|
||||||
if (available < r.Quantity)
|
|
||||||
{
|
|
||||||
var name = await _db.Products
|
|
||||||
.Where(p => p.Id == r.ProductId)
|
|
||||||
.Select(p => p.Name)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
insufficient.Add(new
|
|
||||||
{
|
|
||||||
productId = r.ProductId,
|
|
||||||
productName = name,
|
|
||||||
qty = r.Quantity,
|
|
||||||
available,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (insufficient.Count > 0)
|
|
||||||
{
|
|
||||||
return Conflict(new
|
|
||||||
{
|
|
||||||
error = "Недостаточно остатка для проведения чека.",
|
|
||||||
lines = insufficient,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var line in sale.Lines)
|
foreach (var line in sale.Lines)
|
||||||
{
|
{
|
||||||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
|
builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
|
||||||
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||||||
|
|
||||||
// Sprint 33: модуль «Деньги»
|
// Sprint 33: модуль «Деньги»
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace foodmarket.Application.Inventory;
|
||||||
|
|
||||||
|
/// <summary>Управляет предупреждениями об отрицательных остатках. Вызывается из
|
||||||
|
/// <see cref="IStockService"/> после каждого изменения остатка — единая точка для
|
||||||
|
/// всех документов (продажа/касса/списание/перемещение/возврат/отмена приёмки).
|
||||||
|
///
|
||||||
|
/// Сущности меняются в том же <c>DbContext</c> (tracked) и сохраняются вместе с
|
||||||
|
/// документом (SaveChanges делает вызывающий). Реалтайм-пинг в веб — best-effort.</summary>
|
||||||
|
public interface INegativeStockAlertService
|
||||||
|
{
|
||||||
|
/// <summary>newQty < 0 → создать/обновить открытый алерт по (store, product);
|
||||||
|
/// newQty ≥ 0 → закрыть открытый алерт, если был.</summary>
|
||||||
|
Task OnStockLevelAsync(
|
||||||
|
Guid storeId, Guid productId, decimal newQty,
|
||||||
|
string? causeDocType, Guid? causeDocId, string? causeDocNumber,
|
||||||
|
Guid? causeUserId, DateTime? causeAt,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
49
src/food-market.domain/Inventory/NegativeStockAlert.cs
Normal file
49
src/food-market.domain/Inventory/NegativeStockAlert.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Inventory;
|
||||||
|
|
||||||
|
/// <summary>Предупреждение об отрицательном остатке товара на складе. Создаётся/
|
||||||
|
/// обновляется автоматически при проведении любого документа, после которого
|
||||||
|
/// остаток уходит в минус (продажа, касса, списание, перемещение, возврат
|
||||||
|
/// поставщику, отмена приёмки…). Закрывается автоматически (<see cref="ResolvedAt"/>),
|
||||||
|
/// когда остаток снова ≥ 0.
|
||||||
|
///
|
||||||
|
/// Инвариант: на одну пару (Store, Product) одновременно открыт максимум один
|
||||||
|
/// алерт (ResolvedAt IS NULL) — обеспечивается частичным уникальным индексом.
|
||||||
|
///
|
||||||
|
/// Бизнес разрешает продавать/списывать в минус (решение владельца 08.10), но
|
||||||
|
/// требует срочно провести инвентаризацию по минусовым товарам — отсюда эти алерты
|
||||||
|
/// и постоянные индикаторы в кассе/админке/приложении.</summary>
|
||||||
|
public class NegativeStockAlert : TenantEntity
|
||||||
|
{
|
||||||
|
public Guid StoreId { get; set; }
|
||||||
|
public Store Store { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public Product Product { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Текущий (отрицательный) остаток на момент последнего обновления.</summary>
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
// ── Документ-причина: последний документ, уведший/углубивший минус ──────────
|
||||||
|
public string? CauseDocType { get; set; }
|
||||||
|
public Guid? CauseDocId { get; set; }
|
||||||
|
public string? CauseDocNumber { get; set; }
|
||||||
|
public Guid? CauseUserId { get; set; }
|
||||||
|
public DateTime? CauseAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime FirstDetectedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Когда остаток вернулся ≥ 0. null — минус активен (алерт открыт).</summary>
|
||||||
|
public DateTime? ResolvedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Когда отправлено агрегированное уведомление (push/telegram/email).
|
||||||
|
/// null — ещё не уведомляли (фоновая задача подхватит и разошлёт одним письмом
|
||||||
|
/// на склад). Так достигается дебаунс/агрегация без спама.</summary>
|
||||||
|
public DateTime? NotifiedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Когда отправлена эскалация (минус висит дольше порога) владельцу/админу.</summary>
|
||||||
|
public DateTime? EscalatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Application.Inventory;
|
||||||
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Inventory;
|
||||||
|
|
||||||
|
/// <summary>Создаёт/закрывает <see cref="NegativeStockAlert"/> при изменении остатка.
|
||||||
|
/// Сущности только трекаются в текущем <c>DbContext</c> — SaveChanges делает
|
||||||
|
/// вызывающий документ (атомарно с проводкой).</summary>
|
||||||
|
public sealed class NegativeStockAlertService : INegativeStockAlertService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
|
||||||
|
public NegativeStockAlertService(AppDbContext db, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnStockLevelAsync(
|
||||||
|
Guid storeId, Guid productId, decimal newQty,
|
||||||
|
string? causeDocType, Guid? causeDocId, string? causeDocNumber,
|
||||||
|
Guid? causeUserId, DateTime? causeAt,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var orgId = _tenant.OrganizationId;
|
||||||
|
if (orgId is null) return;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Ищем открытый алерт по (store, product). Учитываем и ещё не сохранённые
|
||||||
|
// (Local) — на случай нескольких движений одного товара в одном документе.
|
||||||
|
var open = _db.NegativeStockAlerts.Local
|
||||||
|
.FirstOrDefault(a => a.StoreId == storeId && a.ProductId == productId && a.ResolvedAt == null)
|
||||||
|
?? await _db.NegativeStockAlerts
|
||||||
|
.FirstOrDefaultAsync(a => a.StoreId == storeId && a.ProductId == productId && a.ResolvedAt == null, ct);
|
||||||
|
|
||||||
|
if (newQty < 0)
|
||||||
|
{
|
||||||
|
if (open is null)
|
||||||
|
{
|
||||||
|
_db.NegativeStockAlerts.Add(new NegativeStockAlert
|
||||||
|
{
|
||||||
|
OrganizationId = orgId.Value,
|
||||||
|
StoreId = storeId,
|
||||||
|
ProductId = productId,
|
||||||
|
Quantity = newQty,
|
||||||
|
CauseDocType = causeDocType,
|
||||||
|
CauseDocId = causeDocId,
|
||||||
|
CauseDocNumber = causeDocNumber,
|
||||||
|
CauseUserId = causeUserId,
|
||||||
|
CauseAt = causeAt ?? now,
|
||||||
|
FirstDetectedAt = now,
|
||||||
|
LastUpdatedAt = now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
open.Quantity = newQty;
|
||||||
|
open.CauseDocType = causeDocType;
|
||||||
|
open.CauseDocId = causeDocId;
|
||||||
|
open.CauseDocNumber = causeDocNumber;
|
||||||
|
open.CauseUserId = causeUserId;
|
||||||
|
open.CauseAt = causeAt ?? now;
|
||||||
|
open.LastUpdatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (open is not null)
|
||||||
|
{
|
||||||
|
// Остаток вернулся ≥ 0 — закрываем.
|
||||||
|
open.Quantity = newQty;
|
||||||
|
open.ResolvedAt = now;
|
||||||
|
open.LastUpdatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,11 +10,13 @@ public class StockService : IStockService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly ITenantContext _tenant;
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly INegativeStockAlertService _negAlerts;
|
||||||
|
|
||||||
public StockService(AppDbContext db, ITenantContext tenant)
|
public StockService(AppDbContext db, ITenantContext tenant, INegativeStockAlertService negAlerts)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_tenant = tenant;
|
_tenant = tenant;
|
||||||
|
_negAlerts = negAlerts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default)
|
public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default)
|
||||||
|
|
@ -56,6 +58,11 @@ public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, Cancellation
|
||||||
stock.Quantity += d.Quantity;
|
stock.Quantity += d.Quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Предупреждение об отрицательном остатке: единая точка детекции для всех
|
||||||
|
// документов. Остаток < 0 → открыть/обновить алерт; ≥ 0 → закрыть.
|
||||||
|
await _negAlerts.OnStockLevelAsync(d.StoreId, d.ProductId, stock.Quantity,
|
||||||
|
d.DocumentType, d.DocumentId, d.DocumentNumber, d.CreatedByUserId, d.OccurredAt, ct);
|
||||||
|
|
||||||
return stock.Quantity;
|
return stock.Quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
|
|
||||||
public DbSet<Stock> Stocks => Set<Stock>();
|
public DbSet<Stock> Stocks => Set<Stock>();
|
||||||
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
||||||
|
public DbSet<NegativeStockAlert> NegativeStockAlerts => Set<NegativeStockAlert>();
|
||||||
|
|
||||||
public DbSet<Supply> Supplies => Set<Supply>();
|
public DbSet<Supply> Supplies => Set<Supply>();
|
||||||
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,23 @@ public static void ConfigureInventory(this ModelBuilder b)
|
||||||
e.HasIndex(x => new { x.OrganizationId, x.StoreId });
|
e.HasIndex(x => new { x.OrganizationId, x.StoreId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
b.Entity<NegativeStockAlert>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("negative_stock_alerts");
|
||||||
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.CauseDocType).HasMaxLength(50);
|
||||||
|
e.Property(x => x.CauseDocNumber).HasMaxLength(50);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Для баннера/отчёта: быстрый выбор открытых по орг/складу.
|
||||||
|
// Частичный УНИКАЛЬНЫЙ индекс «один открытый алерт на (org,store,product)»
|
||||||
|
// добавляется raw-SQL в миграции (EF-differ не дружит с HasFilter здесь).
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.ResolvedAt });
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.StoreId, x.ProductId });
|
||||||
|
});
|
||||||
|
|
||||||
b.Entity<StockMovement>(e =>
|
b.Entity<StockMovement>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("stock_movements");
|
e.ToTable("stock_movements");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Phase39 — предупреждения об отрицательных остатках
|
||||||
|
/// (<c>negative_stock_alerts</c>). Создаётся/обновляется автоматически при уходе
|
||||||
|
/// остатка в минус, закрывается при возврате ≥ 0. Частичный уникальный индекс
|
||||||
|
/// гарантирует максимум один ОТКРЫТЫЙ алерт на (org, store, product). Идемпотентно.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20261009010000_Phase39_NegativeStockAlerts")]
|
||||||
|
public partial class Phase39_NegativeStockAlerts : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS public.negative_stock_alerts (
|
||||||
|
""Id"" uuid PRIMARY KEY,
|
||||||
|
""OrganizationId"" uuid NOT NULL,
|
||||||
|
""StoreId"" uuid NOT NULL,
|
||||||
|
""ProductId"" uuid NOT NULL,
|
||||||
|
""Quantity"" numeric(18,4) NOT NULL,
|
||||||
|
""CauseDocType"" varchar(50),
|
||||||
|
""CauseDocId"" uuid,
|
||||||
|
""CauseDocNumber"" varchar(50),
|
||||||
|
""CauseUserId"" uuid,
|
||||||
|
""CauseAt"" timestamp with time zone,
|
||||||
|
""FirstDetectedAt"" timestamp with time zone NOT NULL,
|
||||||
|
""LastUpdatedAt"" timestamp with time zone NOT NULL,
|
||||||
|
""ResolvedAt"" timestamp with time zone,
|
||||||
|
""NotifiedAt"" timestamp with time zone,
|
||||||
|
""EscalatedAt"" timestamp with time zone,
|
||||||
|
""CreatedAt"" timestamp with time zone NOT NULL,
|
||||||
|
""UpdatedAt"" timestamp with time zone,
|
||||||
|
CONSTRAINT ""FK_negative_stock_alerts_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ""FK_negative_stock_alerts_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ""IX_negative_stock_alerts_OrganizationId_ResolvedAt""
|
||||||
|
ON public.negative_stock_alerts (""OrganizationId"", ""ResolvedAt"");
|
||||||
|
CREATE INDEX IF NOT EXISTS ""IX_negative_stock_alerts_OrganizationId_StoreId_ProductId""
|
||||||
|
ON public.negative_stock_alerts (""OrganizationId"", ""StoreId"", ""ProductId"");
|
||||||
|
-- Максимум один ОТКРЫТЫЙ алерт на (org, store, product).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ""UX_negative_stock_alerts_open""
|
||||||
|
ON public.negative_stock_alerts (""OrganizationId"", ""StoreId"", ""ProductId"")
|
||||||
|
WHERE ""ResolvedAt"" IS NULL;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"DROP TABLE IF EXISTS public.negative_stock_alerts;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import { QuickActionsPalette } from './QuickActionsPalette'
|
||||||
import { FeedbackWidget } from './FeedbackWidget'
|
import { FeedbackWidget } from './FeedbackWidget'
|
||||||
import { WhatsNewBanner } from './WhatsNewBanner'
|
import { WhatsNewBanner } from './WhatsNewBanner'
|
||||||
import { NotificationCenter } from './NotificationCenter'
|
import { NotificationCenter } from './NotificationCenter'
|
||||||
|
import { NegativeStockBanner } from './NegativeStockBanner'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -367,6 +368,7 @@ export function AppLayout() {
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||||
<SuperAdminAsOrgBanner />
|
<SuperAdminAsOrgBanner />
|
||||||
|
<NegativeStockBanner />
|
||||||
<WhatsNewBanner />
|
<WhatsNewBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
85
src/food-market.web/src/components/NegativeStockBanner.tsx
Normal file
85
src/food-market.web/src/components/NegativeStockBanner.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useNegativeStockCount } from '@/lib/useNegativeStock'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Постоянный баннер «N товаров в минусе — проведите инвентаризацию». Висит на
|
||||||
|
* всех страницах, пока минусы не закрыты. Закрыть крестиком нельзя — можно
|
||||||
|
* свернуть (решение владельца 08.10). Кнопка «Пересчитать эти товары» создаёт
|
||||||
|
* черновик инвентаризации по складу с минусами и открывает его.
|
||||||
|
*/
|
||||||
|
export function NegativeStockBanner() {
|
||||||
|
const { data } = useNegativeStockCount()
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const total = data?.total ?? 0
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
const stores = data?.byStore ?? []
|
||||||
|
|
||||||
|
const recount = async () => {
|
||||||
|
if (busy || stores.length === 0) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
// Пересчитываем склад с минусами. Если складов несколько — берём первый;
|
||||||
|
// остальные останутся в баннере и пересчитаются следующими нажатиями.
|
||||||
|
const target = stores[0]
|
||||||
|
const res = await api.post<{ inventoryId: string }>('/api/negative-stock/recount', { storeId: target.storeId })
|
||||||
|
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
||||||
|
} catch {
|
||||||
|
// покажем список, чтобы пользователь разобрался вручную
|
||||||
|
navigate('/inventory/stock?negativeOnly=true')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const word = total === 1 ? 'товар' : total < 5 ? 'товара' : 'товаров'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-950/40 border-b border-amber-300 dark:border-amber-800">
|
||||||
|
<div className="px-4 py-2 flex items-center gap-3 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 shrink-0" aria-hidden="true" />
|
||||||
|
<span className="font-medium text-amber-900 dark:text-amber-200">
|
||||||
|
{total} {word} в минусе — нужна инвентаризация
|
||||||
|
</span>
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="text-amber-700 dark:text-amber-300/80 hidden sm:inline truncate">
|
||||||
|
{stores.map((s) => `${s.storeName}: ${s.count}`).join(' · ')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={recount}
|
||||||
|
disabled={busy}
|
||||||
|
className="ml-auto inline-flex items-center gap-1.5 px-3 py-1 rounded-md bg-amber-600 text-white text-xs font-semibold hover:bg-amber-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
|
||||||
|
Пересчитать эти товары
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/inventory/stock?negativeOnly=true')}
|
||||||
|
className="text-xs text-amber-800 dark:text-amber-300 underline hover:text-amber-900"
|
||||||
|
>
|
||||||
|
Список
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
className={`text-amber-700 hover:text-amber-900 dark:text-amber-300 ${collapsed ? 'ml-auto' : ''}`}
|
||||||
|
aria-label={collapsed ? 'Развернуть' : 'Свернуть'}
|
||||||
|
title={collapsed ? 'Развернуть' : 'Свернуть'}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/food-market.web/src/lib/useNegativeStock.ts
Normal file
43
src/food-market.web/src/lib/useNegativeStock.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
export interface NegStoreCount { storeId: string; storeName: string; count: number }
|
||||||
|
export interface NegCount { total: number; byStore: NegStoreCount[] }
|
||||||
|
|
||||||
|
export interface NegAlert {
|
||||||
|
id: string
|
||||||
|
storeId: string
|
||||||
|
storeName: string
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
quantity: number
|
||||||
|
causeDocType: string | null
|
||||||
|
causeDocNumber: string | null
|
||||||
|
causeAt: string | null
|
||||||
|
firstDetectedAt: string
|
||||||
|
lastUpdatedAt: string
|
||||||
|
overdue: boolean
|
||||||
|
hint: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Счётчик товаров в минусе (для баннера/индикатора). Обновляется каждые 60с и
|
||||||
|
* при событиях продажи/приёмки — инвалидация делается в NotificationCenter-хабе. */
|
||||||
|
export function useNegativeStockCount(storeId?: string) {
|
||||||
|
return useQuery<NegCount>({
|
||||||
|
queryKey: ['/api/negative-stock/count', storeId ?? null],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<NegCount>(`/api/negative-stock/count${storeId ? `?storeId=${storeId}` : ''}`)).data,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список открытых предупреждений (товар, склад, минус, причина, подсказка). */
|
||||||
|
export function useNegativeStockAlerts(storeId?: string) {
|
||||||
|
return useQuery<NegAlert[]>({
|
||||||
|
queryKey: ['/api/negative-stock', storeId ?? null],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<NegAlert[]>(`/api/negative-stock${storeId ? `?storeId=${storeId}` : ''}`)).data,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { ExportButton } from '@/components/ExportButton'
|
import { ExportButton } from '@/components/ExportButton'
|
||||||
|
|
@ -16,18 +17,21 @@ export function StockPage() {
|
||||||
const [storeId, setStoreId] = useState('')
|
const [storeId, setStoreId] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [includeZero, setIncludeZero] = useState(false)
|
const [includeZero, setIncludeZero] = useState(false)
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [negativeOnly, setNegativeOnly] = useState(searchParams.get('negativeOnly') === 'true')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||||
const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) }
|
const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) }
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['/api/inventory/stock', { storeId, search, includeZero, page, sortKey, sortOrder }],
|
queryKey: ['/api/inventory/stock', { storeId, search, includeZero, negativeOnly, page, sortKey, sortOrder }],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
||||||
if (storeId) params.set('storeId', storeId)
|
if (storeId) params.set('storeId', storeId)
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
if (includeZero) params.set('includeZero', 'true')
|
if (negativeOnly) params.set('negativeOnly', 'true')
|
||||||
|
else if (includeZero) params.set('includeZero', 'true')
|
||||||
if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) }
|
if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) }
|
||||||
return (await api.get<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
|
return (await api.get<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
|
||||||
},
|
},
|
||||||
|
|
@ -47,6 +51,7 @@ export function StockPage() {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
|
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
|
||||||
|
<Checkbox label="Только минусы" checked={negativeOnly} onChange={(v) => { setNegativeOnly(v); setPage(1) }} />
|
||||||
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
|
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
|
||||||
<ExportButton url="/api/inventory/stock/export" params={{ storeId, search, includeZero }} defaultName="stock" />
|
<ExportButton url="/api/inventory/stock/export" params={{ storeId, search, includeZero }} defaultName="stock" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -71,7 +76,11 @@ export function StockPage() {
|
||||||
)},
|
)},
|
||||||
{ header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName },
|
{ header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName },
|
||||||
{ header: 'Ед.', width: '80px', sortKey: 'unit', cell: (r) => r.unitName },
|
{ header: 'Ед.', width: '80px', sortKey: 'unit', cell: (r) => r.unitName },
|
||||||
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', sortKey: 'quantity', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) },
|
{ header: 'Остаток', width: '130px', className: 'text-right font-mono', sortKey: 'quantity', cell: (r) => (
|
||||||
|
<span className={r.quantity < 0 ? 'text-red-600 font-semibold' : ''} title={r.quantity < 0 ? 'Отрицательный остаток — нужна инвентаризация' : undefined}>
|
||||||
|
{r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })}
|
||||||
|
</span>
|
||||||
|
) },
|
||||||
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', sortKey: 'reserved', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
|
{ header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', sortKey: 'reserved', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' },
|
||||||
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', sortKey: 'available', cell: (r) => (
|
{ header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', sortKey: 'available', cell: (r) => (
|
||||||
<span className={r.available < 0 ? 'text-red-600' : r.available === 0 ? 'text-slate-400' : ''}>
|
<span className={r.available < 0 ? 'text-red-600' : r.available === 0 ? 'text-slate-400' : ''}>
|
||||||
|
|
|
||||||
|
|
@ -901,13 +901,22 @@ export async function step09_cashier_shift({ ctx, step, report }: StepCtx) {
|
||||||
const sale3 = await makeSale(api, ctx, { productId: prods[2].id, qty: 1, unitPrice: prods[2].price, method: 99, pointId: ctx.point1Id })
|
const sale3 = await makeSale(api, ctx, { productId: prods[2].id, qty: 1, unitPrice: prods[2].price, method: 99, pointId: ctx.point1Id })
|
||||||
rec(step, 'api', 'Продажа 3 (смешанная) проведена', sale3.postStatus === 200 || sale3.postStatus === 204, '204', `${sale3.postStatus}`)
|
rec(step, 'api', 'Продажа 3 (смешанная) проведена', sale3.postStatus === 200 || sale3.postStatus === 204, '204', `${sale3.postStatus}`)
|
||||||
|
|
||||||
// продажа больше остатка → отказ
|
// продажа больше остатка → ПРОВЕДЕНО (минус разрешён, решение владельца 08.10)
|
||||||
|
// + появляется предупреждение об отрицательном остатке.
|
||||||
const over = await makeSale(api, ctx, { productId: prods[0].id, qty: 999999, unitPrice: prods[0].price, method: 0, pointId: ctx.point1Id })
|
const over = await makeSale(api, ctx, { productId: prods[0].id, qty: 999999, unitPrice: prods[0].price, method: 0, pointId: ctx.point1Id })
|
||||||
rec(step, 'api', 'Продажа больше остатка → отказ (409)', over.postStatus === 409, 409, over.postStatus)
|
rec(step, 'api', 'Продажа больше остатка → проведено (минус разрешён)', over.postStatus === 200 || over.postStatus === 204, '204', over.postStatus)
|
||||||
if (over.id && over.postStatus !== 409) {
|
if (over.postStatus !== 200 && over.postStatus !== 204) {
|
||||||
report.bug({ step: '09', severity: 'high', title: 'Продажа больше остатка прошла', detail: `post=${over.postStatus}` })
|
report.bug({ step: '09', severity: 'high', title: 'Продажа в минус заблокирована (минус должен быть разрешён)', detail: `post=${over.postStatus}` })
|
||||||
|
}
|
||||||
|
const negCnt = await api.get(`/api/negative-stock/count?storeId=${ctx.storeId}`)
|
||||||
|
const negTotal = negCnt.data?.total ?? 0
|
||||||
|
rec(step, 'api', 'После продажи в минус появилось предупреждение', negTotal >= 1, '>=1', negTotal)
|
||||||
|
if (negTotal < 1) report.bug({ step: '09', severity: 'high', title: 'Продажа в минус не создала предупреждение', detail: `count=${negTotal}` })
|
||||||
|
// Откат: снимаем с проведения и удаляем — остаток восстановится, предупреждение закроется.
|
||||||
|
if (over.id) {
|
||||||
|
await api.post(`/api/sales/retail/${over.id}/unpost`, {})
|
||||||
|
await api.delete(`/api/sales/retail/${over.id}`)
|
||||||
}
|
}
|
||||||
if (over.id) await api.delete(`/api/sales/retail/${over.id}`)
|
|
||||||
|
|
||||||
// возврат от покупателя по чеку (частичный, наличными)
|
// возврат от покупателя по чеку (частичный, наличными)
|
||||||
let returnCash = 0
|
let returnCash = 0
|
||||||
|
|
@ -1351,11 +1360,14 @@ export async function step13_cancel_rollback({ ctx, step, report }: StepCtx) {
|
||||||
if (!api || ctx.supplies.length < 1) { step.status = 'skip'; return }
|
if (!api || ctx.supplies.length < 1) { step.status = 'skip'; return }
|
||||||
const s1 = ctx.supplies[0]
|
const s1 = ctx.supplies[0]
|
||||||
|
|
||||||
// отмена проведения приёмки с уже проданным товаром → понятный отказ
|
// отмена проведения приёмки с уже проданным товаром → ПРОВЕДЕНО (минус разрешён),
|
||||||
|
// остаток может уйти в минус, появляется предупреждение. Затем вернём обратно (re-post).
|
||||||
const unpost = await api.post(`/api/purchases/supplies/${s1.id}/unpost`, {})
|
const unpost = await api.post(`/api/purchases/supplies/${s1.id}/unpost`, {})
|
||||||
const refused = unpost.status >= 400
|
const ok = unpost.status === 200 || unpost.status === 204
|
||||||
rec(step, 'api', 'Отмена приёмки с проданным товаром → отказ', refused, '4xx', unpost.status)
|
rec(step, 'api', 'Отмена приёмки с проданным товаром → проведено (минус разрешён)', ok, '204', unpost.status)
|
||||||
if (!refused) report.bug({ step: '13', severity: 'high', title: 'Отмена приёмки с проданным товаром прошла (остаток ушёл в минус?)', detail: `unpost=${unpost.status}` })
|
if (!ok) report.bug({ step: '13', severity: 'high', title: 'Отмена приёмки заблокирована (минус должен быть разрешён)', detail: `unpost=${unpost.status}` })
|
||||||
|
// Возвращаем приёмку обратно, чтобы не ломать остальные проверки отката.
|
||||||
|
if (ok) await api.post(`/api/purchases/supplies/${s1.id}/post`, {})
|
||||||
|
|
||||||
// отмена платежа → долг вернулся
|
// отмена платежа → долг вернулся
|
||||||
const pay1 = ctx.pay.find((p) => p.supplyIdx === 0)
|
const pay1 = ctx.pay.find((p) => p.supplyIdx === 0)
|
||||||
|
|
|
||||||
107
tests/food-market.IntegrationTests/NegativeStockTests.cs
Normal file
107
tests/food-market.IntegrationTests/NegativeStockTests.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class NegativeStockTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public NegativeStockTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
private static string RandomBarcode()
|
||||||
|
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||||||
|
|
||||||
|
private async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
|
||||||
|
{
|
||||||
|
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
notes = "seed", lines = new[] { new { productId = productId, quantity = qty, unitCost = 50m } },
|
||||||
|
});
|
||||||
|
enter.EnsureSuccessStatusCode();
|
||||||
|
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||||
|
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Loss_into_negative_is_allowed_creates_alert_recount_and_resolves_on_restock()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}");
|
||||||
|
var refs = await api.LoadRefsAsync();
|
||||||
|
var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||||
|
|
||||||
|
// 5 шт на складе, минусов нет.
|
||||||
|
await SeedStockAsync(api, refs, p, 5m);
|
||||||
|
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(5m);
|
||||||
|
var c0 = await api.GetJsonAsync("/api/negative-stock/count");
|
||||||
|
c0.GetProperty("total").GetInt32().Should().Be(0);
|
||||||
|
|
||||||
|
// Списание 8 → остаток −3. Списание в минус РАЗРЕШЕНО (решение владельца).
|
||||||
|
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
|
||||||
|
{
|
||||||
|
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||||
|
reason = 0, notes = "тест минус",
|
||||||
|
lines = new[] { new { productId = p, quantity = 8m, unitCost = 50m } },
|
||||||
|
});
|
||||||
|
loss.EnsureSuccessStatusCode();
|
||||||
|
var lid = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||||
|
using var lpost = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { });
|
||||||
|
lpost.IsSuccessStatusCode.Should().BeTrue($"списание в минус разрешено: {(int)lpost.StatusCode} {await lpost.Content.ReadAsStringAsync()}");
|
||||||
|
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(-3m);
|
||||||
|
|
||||||
|
// Предупреждение создано.
|
||||||
|
var c1 = await api.GetJsonAsync("/api/negative-stock/count");
|
||||||
|
c1.GetProperty("total").GetInt32().Should().Be(1);
|
||||||
|
var alerts = await api.ListAsync("/api/negative-stock");
|
||||||
|
alerts.Should().ContainSingle();
|
||||||
|
alerts[0].GetProperty("quantity").GetDecimal().Should().Be(-3m);
|
||||||
|
alerts[0].GetProperty("productId").GetString().Should().Be(p);
|
||||||
|
alerts[0].GetProperty("causeDocType").GetString().Should().Be("loss");
|
||||||
|
|
||||||
|
// «Пересчитать эти товары» → черновик инвентаризации с этим товаром.
|
||||||
|
var rc = await api.Http.PostAsJsonAsync("/api/negative-stock/recount", new { storeId = refs.StoreId });
|
||||||
|
rc.EnsureSuccessStatusCode();
|
||||||
|
var rcj = await rc.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
rcj.GetProperty("lines").GetInt32().Should().Be(1);
|
||||||
|
var invId = rcj.GetProperty("inventoryId").GetString();
|
||||||
|
var inv = await api.GetJsonAsync($"/api/inventory/inventories/{invId}");
|
||||||
|
inv.GetProperty("lines").EnumerateArray()
|
||||||
|
.Should().ContainSingle(l => l.GetProperty("productId").GetString() == p);
|
||||||
|
|
||||||
|
// Довоз 3 → остаток 0 → предупреждение закрылось автоматически.
|
||||||
|
await SeedStockAsync(api, refs, p, 3m);
|
||||||
|
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(0m);
|
||||||
|
var c2 = await api.GetJsonAsync("/api/negative-stock/count");
|
||||||
|
c2.GetProperty("total").GetInt32().Should().Be(0);
|
||||||
|
(await api.ListAsync("/api/negative-stock")).Should().BeEmpty("минус закрыт — алерт resolved");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Negative_stock_report_filter_returns_only_negatives()
|
||||||
|
{
|
||||||
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
|
await api.SignupAndLoginAsync($"neg-rep-{Guid.NewGuid():N}");
|
||||||
|
var refs = await api.LoadRefsAsync();
|
||||||
|
var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||||
|
|
||||||
|
await SeedStockAsync(api, refs, p, 2m);
|
||||||
|
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();
|
||||||
|
|
||||||
|
var neg = await api.GetJsonAsync("/api/inventory/stock?negativeOnly=true&pageSize=200");
|
||||||
|
var items = neg.GetProperty("items").EnumerateArray().ToList();
|
||||||
|
items.Should().NotBeEmpty();
|
||||||
|
items.Should().OnlyContain(x => x.GetProperty("quantity").GetDecimal() < 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue