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

Решение владельца 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:
nns 2026-10-09 02:18:29 +05:00
parent b095329c5a
commit 9ba1edb236
25 changed files with 735 additions and 227 deletions

1
.gitignore vendored
View file

@ -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/

View 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 — старт: прогресс-док, сверка с МойСкладом, обследование кода.

View file

@ -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);

View file

@ -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}";
}
}

View file

@ -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()}%";

View file

@ -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(

View file

@ -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.

View file

@ -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);

View file

@ -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)

View file

@ -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);

View file

@ -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(

View file

@ -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: модуль «Деньги»

View file

@ -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 &lt; 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);
}

View 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; }
}

View file

@ -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;
}
}
}

View file

@ -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;
} }

View file

@ -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>();

View file

@ -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");

View file

@ -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;");
}
}
}

View file

@ -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>

View 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>
)
}

View 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,
})
}

View file

@ -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' : ''}>

View file

@ -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)

View 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);
}
}