diff --git a/.gitignore b/.gitignore index 956e073..b8bc629 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ src/food-market.public/node_modules/ ## POS build artifacts (self-contained сборки кассы — не в git) deploy/pos-dist/ tests/**/TestResults/ +src/food-market.api/uploads/ diff --git a/docs/negative-stock-alerts-progress.md b/docs/negative-stock-alerts-progress.md new file mode 100644 index 0000000..f9f6034 --- /dev/null +++ b/docs/negative-stock-alerts-progress.md @@ -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 — старт: прогресс-док, сверка с МойСкладом, обследование кода. diff --git a/src/food-market.api/Controllers/Inventory/LossesController.cs b/src/food-market.api/Controllers/Inventory/LossesController.cs index 3f82e00..4685b9f 100644 --- a/src/food-market.api/Controllers/Inventory/LossesController.cs +++ b/src/food-market.api/Controllers/Inventory/LossesController.cs @@ -246,38 +246,8 @@ public async Task Post(Guid id, CancellationToken ct) if (loss.Status == LossStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (loss.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); - // Защита от ухода stock в минус. - 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(); - 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, - }); - } + // Списание в минус РАЗРЕШЕНО (решение владельца 08.10): остаток может уйти + // ниже нуля; при проведении создаётся предупреждение об отрицательном остатке. await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); diff --git a/src/food-market.api/Controllers/Inventory/NegativeStockController.cs b/src/food-market.api/Controllers/Inventory/NegativeStockController.cs new file mode 100644 index 0000000..ff4586c --- /dev/null +++ b/src/food-market.api/Controllers/Inventory/NegativeStockController.cs @@ -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; + +/// Предупреждения об отрицательных остатках: список открытых, счётчик для +/// баннеров/индикаторов (касса/админка/приложение) и действие «Пересчитать эти +/// товары» (черновик инвентаризации по складу ровно с минусовыми товарами). +/// +/// Остатки в минус разрешены (решение владельца 08.10), но требуют срочной +/// инвентаризации — отсюда эти эндпоинты и постоянные индикаторы. +[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 byStore); + + /// Открытые предупреждения (ResolvedAt IS NULL). Фильтр по складу опц. + /// Для каждого — подсказка причины, если есть непроведённая приёмка по товару. + [HttpGet, RequiresPermission("StocksView")] + public async Task>> 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; + } + + /// Счётчик открытых минусов (для жёлтого индикатора в кассе/баннера в + /// админке/счётчика в приложении), с разбивкой по складам. + [HttpGet("count")] + public async Task> 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); + + /// «Пересчитать эти товары»: создаёт ЧЕРНОВИК инвентаризации по складу + /// ровно с теми товарами, что сейчас в минусе (BookQty = текущий отрицательный + /// остаток). Кладовщик вносит факт и проводит — минусы закрываются. + [HttpPost("recount"), RequiresPermission("InventoryEdit")] + public async Task> 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 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}"; + } +} diff --git a/src/food-market.api/Controllers/Inventory/StockController.cs b/src/food-market.api/Controllers/Inventory/StockController.cs index f22b396..2f33def 100644 --- a/src/food-market.api/Controllers/Inventory/StockController.cs +++ b/src/food-market.api/Controllers/Inventory/StockController.cs @@ -27,6 +27,7 @@ public record StockRow( [FromQuery] Guid? groupId, [FromQuery] string? search, [FromQuery] bool includeZero = false, + [FromQuery] bool negativeOnly = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] string? sort = null, @@ -46,7 +47,8 @@ public record StockRow( var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct); 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)) { var term = $"%{search.Trim()}%"; diff --git a/src/food-market.api/Controllers/Inventory/TransfersController.cs b/src/food-market.api/Controllers/Inventory/TransfersController.cs index 2cd9f6a..ff73ea3 100644 --- a/src/food-market.api/Controllers/Inventory/TransfersController.cs +++ b/src/food-market.api/Controllers/Inventory/TransfersController.cs @@ -245,38 +245,8 @@ public async Task Post(Guid id, CancellationToken ct) if (t.Status == TransferStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (t.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); - // Проверка: на FromStore хватает товара (нельзя уйти в минус). - 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(); - 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, - }); - } + // Перемещение в минус РАЗРЕШЕНО (решение владельца 08.10): остаток склада- + // отправителя может уйти ниже нуля; при проведении создаётся предупреждение. // Атомарная транзакция: пара движений (Out + In) — либо обе, либо ни одной. await using var tx = await _db.Database.BeginTransactionAsync( diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index 94ec073..4253714 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -330,25 +330,10 @@ orderby l.SortOrder if (src.Lines.Count == 0) throw new PosSaleRejectedException("Пустой чек.", "lines"); - // Возврат покупателю добавляет остаток, поэтому pre-flight не нужен; для - // продажи проверяем остаток до создания RetailSale, чтобы зря не дёргать БД. - if (!src.IsReturn) - { - 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) + // Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку + // убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата + // по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано). + if (src.IsReturn && src.ReferenceSaleId is Guid refIdCheck) { // Возврат по чеку: нельзя вернуть больше, чем продано минус уже // возвращено (QtyReturned на исходных строках). Защита от over-return. diff --git a/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs b/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs index 9b9b0bf..1bed065 100644 --- a/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs +++ b/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs @@ -267,31 +267,8 @@ public async Task Post(Guid id, CancellationToken ct) if (r.Status == SupplierReturnStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (r.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); - // Защита от ухода в минус. - 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(); - 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 }); + // Возврат поставщику в минус РАЗРЕШЁН (решение владельца 08.10): остаток + // может уйти ниже нуля; при проведении создаётся предупреждение. await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index be313da..1be8803 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -522,46 +522,9 @@ public async Task Unpost(Guid id, CancellationToken ct) if (supply is null) return NotFound(); if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." }); - // Защита от ухода stock в минус: суммируем то, что вернём (по строкам - // с тем же ProductId — могут быть дубли), сравниваем с текущим Stock. - // Если приёмку «расковать», stock уменьшится на line.Quantity. Если - // покупатели уже что-то купили из этой партии, остаток станет - // отрицательным — это нарушение инварианта учёта. - 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(); - 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, - }); - } + // Отмена приёмки в минус РАЗРЕШЕНА (решение владельца 08.10): если часть + // полученного товара уже продана, остаток уйдёт ниже нуля — это допустимо, + // при проведении (reversal) создаётся предупреждение об отрицательном остатке. // Reverse: negative movements with same document reference foreach (var line in supply.Lines) diff --git a/src/food-market.api/Controllers/Sales/DemandsController.cs b/src/food-market.api/Controllers/Sales/DemandsController.cs index fc3531f..ff3ead3 100644 --- a/src/food-market.api/Controllers/Sales/DemandsController.cs +++ b/src/food-market.api/Controllers/Sales/DemandsController.cs @@ -230,30 +230,8 @@ public async Task Post(Guid id, CancellationToken ct) if (demand.Status == DemandStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (demand.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); - // Защита от ухода в минус. - 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(); - 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 }); - + // Отгрузка в минус РАЗРЕШЕНА (решение владельца 08.10): остаток может уйти + // ниже нуля, при проведении создаётся предупреждение об отрицательном остатке. await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index e6d6106..ea38708 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -626,47 +626,10 @@ private async Task PostCoreAsync(Guid id, CancellationToken ct) await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); - // Если в одном чеке один и тот же продукт встречается несколько раз, - // нужно сравнить с остатком СУММУ всех строк, а не каждую отдельно. - var requested = sale.Lines - .GroupBy(l => l.ProductId) - .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(); - 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, - }); - } - + // Продажа в минус РАЗРЕШЕНА (решение владельца 08.10): остаток может уйти + // ниже нуля. Блокировки «недостаточно остатка» больше нет — вместо неё при + // проведении автоматически создаётся предупреждение об отрицательном остатке + // (StockService → NegativeStockAlert), с требованием провести инвентаризацию. foreach (var line in sale.Lines) { await _stock.ApplyMovementAsync(new StockMovementDraft( diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 2104ce4..ef7d1b8 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -437,6 +437,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddSingleton(); // Inventory + builder.Services.AddScoped(); builder.Services.AddScoped(); // Sprint 33: модуль «Деньги» diff --git a/src/food-market.application/Inventory/INegativeStockAlertService.cs b/src/food-market.application/Inventory/INegativeStockAlertService.cs new file mode 100644 index 0000000..4b5160d --- /dev/null +++ b/src/food-market.application/Inventory/INegativeStockAlertService.cs @@ -0,0 +1,18 @@ +namespace foodmarket.Application.Inventory; + +/// Управляет предупреждениями об отрицательных остатках. Вызывается из +/// после каждого изменения остатка — единая точка для +/// всех документов (продажа/касса/списание/перемещение/возврат/отмена приёмки). +/// +/// Сущности меняются в том же DbContext (tracked) и сохраняются вместе с +/// документом (SaveChanges делает вызывающий). Реалтайм-пинг в веб — best-effort. +public interface INegativeStockAlertService +{ + /// newQty < 0 → создать/обновить открытый алерт по (store, product); + /// newQty ≥ 0 → закрыть открытый алерт, если был. + Task OnStockLevelAsync( + Guid storeId, Guid productId, decimal newQty, + string? causeDocType, Guid? causeDocId, string? causeDocNumber, + Guid? causeUserId, DateTime? causeAt, + CancellationToken ct = default); +} diff --git a/src/food-market.domain/Inventory/NegativeStockAlert.cs b/src/food-market.domain/Inventory/NegativeStockAlert.cs new file mode 100644 index 0000000..32abd3c --- /dev/null +++ b/src/food-market.domain/Inventory/NegativeStockAlert.cs @@ -0,0 +1,49 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Inventory; + +/// Предупреждение об отрицательном остатке товара на складе. Создаётся/ +/// обновляется автоматически при проведении любого документа, после которого +/// остаток уходит в минус (продажа, касса, списание, перемещение, возврат +/// поставщику, отмена приёмки…). Закрывается автоматически (), +/// когда остаток снова ≥ 0. +/// +/// Инвариант: на одну пару (Store, Product) одновременно открыт максимум один +/// алерт (ResolvedAt IS NULL) — обеспечивается частичным уникальным индексом. +/// +/// Бизнес разрешает продавать/списывать в минус (решение владельца 08.10), но +/// требует срочно провести инвентаризацию по минусовым товарам — отсюда эти алерты +/// и постоянные индикаторы в кассе/админке/приложении. +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!; + + /// Текущий (отрицательный) остаток на момент последнего обновления. + 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; + + /// Когда остаток вернулся ≥ 0. null — минус активен (алерт открыт). + public DateTime? ResolvedAt { get; set; } + + /// Когда отправлено агрегированное уведомление (push/telegram/email). + /// null — ещё не уведомляли (фоновая задача подхватит и разошлёт одним письмом + /// на склад). Так достигается дебаунс/агрегация без спама. + public DateTime? NotifiedAt { get; set; } + + /// Когда отправлена эскалация (минус висит дольше порога) владельцу/админу. + public DateTime? EscalatedAt { get; set; } +} diff --git a/src/food-market.infrastructure/Inventory/NegativeStockAlertService.cs b/src/food-market.infrastructure/Inventory/NegativeStockAlertService.cs new file mode 100644 index 0000000..dfda10c --- /dev/null +++ b/src/food-market.infrastructure/Inventory/NegativeStockAlertService.cs @@ -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; + +/// Создаёт/закрывает при изменении остатка. +/// Сущности только трекаются в текущем DbContext — SaveChanges делает +/// вызывающий документ (атомарно с проводкой). +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; + } + } +} diff --git a/src/food-market.infrastructure/Inventory/StockService.cs b/src/food-market.infrastructure/Inventory/StockService.cs index 2f6afbc..0b1c228 100644 --- a/src/food-market.infrastructure/Inventory/StockService.cs +++ b/src/food-market.infrastructure/Inventory/StockService.cs @@ -10,11 +10,13 @@ public class StockService : IStockService { private readonly AppDbContext _db; private readonly ITenantContext _tenant; + private readonly INegativeStockAlertService _negAlerts; - public StockService(AppDbContext db, ITenantContext tenant) + public StockService(AppDbContext db, ITenantContext tenant, INegativeStockAlertService negAlerts) { _db = db; _tenant = tenant; + _negAlerts = negAlerts; } public async Task ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default) @@ -56,6 +58,11 @@ public async Task ApplyMovementAsync(StockMovementDraft d, Cancellation 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; } diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 8d49aef..aaa924e 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -41,6 +41,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Stocks => Set(); public DbSet StockMovements => Set(); + public DbSet NegativeStockAlerts => Set(); public DbSet Supplies => Set(); public DbSet SupplyLines => Set(); diff --git a/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs index fa05e74..ace397f 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs @@ -21,6 +21,23 @@ public static void ConfigureInventory(this ModelBuilder b) e.HasIndex(x => new { x.OrganizationId, x.StoreId }); }); + b.Entity(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(e => { e.ToTable("stock_movements"); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009010000_Phase39_NegativeStockAlerts.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009010000_Phase39_NegativeStockAlerts.cs new file mode 100644 index 0000000..7dbc43a --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009010000_Phase39_NegativeStockAlerts.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase39 — предупреждения об отрицательных остатках + /// (negative_stock_alerts). Создаётся/обновляется автоматически при уходе + /// остатка в минус, закрывается при возврате ≥ 0. Частичный уникальный индекс + /// гарантирует максимум один ОТКРЫТЫЙ алерт на (org, store, product). Идемпотентно. + [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;"); + } + } +} diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index ead9667..d9c7e2b 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -21,6 +21,7 @@ import { QuickActionsPalette } from './QuickActionsPalette' import { FeedbackWidget } from './FeedbackWidget' import { WhatsNewBanner } from './WhatsNewBanner' import { NotificationCenter } from './NotificationCenter' +import { NegativeStockBanner } from './NegativeStockBanner' interface MeResponse { sub: string @@ -367,6 +368,7 @@ export function AppLayout() {
+
diff --git a/src/food-market.web/src/components/NegativeStockBanner.tsx b/src/food-market.web/src/components/NegativeStockBanner.tsx new file mode 100644 index 0000000..2402d0a --- /dev/null +++ b/src/food-market.web/src/components/NegativeStockBanner.tsx @@ -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 ( +
+
+
+
+ ) +} diff --git a/src/food-market.web/src/lib/useNegativeStock.ts b/src/food-market.web/src/lib/useNegativeStock.ts new file mode 100644 index 0000000..d4cb074 --- /dev/null +++ b/src/food-market.web/src/lib/useNegativeStock.ts @@ -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({ + queryKey: ['/api/negative-stock/count', storeId ?? null], + queryFn: async () => + (await api.get(`/api/negative-stock/count${storeId ? `?storeId=${storeId}` : ''}`)).data, + refetchInterval: 60_000, + staleTime: 15_000, + }) +} + +/** Список открытых предупреждений (товар, склад, минус, причина, подсказка). */ +export function useNegativeStockAlerts(storeId?: string) { + return useQuery({ + queryKey: ['/api/negative-stock', storeId ?? null], + queryFn: async () => + (await api.get(`/api/negative-stock${storeId ? `?storeId=${storeId}` : ''}`)).data, + refetchInterval: 60_000, + }) +} diff --git a/src/food-market.web/src/pages/StockPage.tsx b/src/food-market.web/src/pages/StockPage.tsx index 9758fd5..9527975 100644 --- a/src/food-market.web/src/pages/StockPage.tsx +++ b/src/food-market.web/src/pages/StockPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { ListPageShell } from '@/components/ListPageShell' import { ExportButton } from '@/components/ExportButton' @@ -16,18 +17,21 @@ export function StockPage() { const [storeId, setStoreId] = useState('') const [search, setSearch] = useState('') const [includeZero, setIncludeZero] = useState(false) + const [searchParams] = useSearchParams() + const [negativeOnly, setNegativeOnly] = useState(searchParams.get('negativeOnly') === 'true') const [page, setPage] = useState(1) const [sortKey, setSortKey] = useState(null) const [sortOrder, setSortOrder] = useState('asc') const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) } 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 () => { const params = new URLSearchParams({ page: String(page), pageSize: '50' }) if (storeId) params.set('storeId', storeId) 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) } return (await api.get>(`/api/inventory/stock?${params}`)).data }, @@ -47,6 +51,7 @@ export function StockPage() { { setIncludeZero(v); setPage(1) }} /> + { setNegativeOnly(v); setPage(1) }} /> { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" /> @@ -71,7 +76,11 @@ export function StockPage() { )}, { header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName }, { 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) => ( + + {r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })} + + ) }, { 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) => ( diff --git a/tests/e2e/scenarios/full-chain.steps.ts b/tests/e2e/scenarios/full-chain.steps.ts index 5307f00..64a061c 100644 --- a/tests/e2e/scenarios/full-chain.steps.ts +++ b/tests/e2e/scenarios/full-chain.steps.ts @@ -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 }) 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 }) - rec(step, 'api', 'Продажа больше остатка → отказ (409)', over.postStatus === 409, 409, over.postStatus) - if (over.id && over.postStatus !== 409) { - report.bug({ step: '09', severity: 'high', title: 'Продажа больше остатка прошла', detail: `post=${over.postStatus}` }) + rec(step, 'api', 'Продажа больше остатка → проведено (минус разрешён)', over.postStatus === 200 || over.postStatus === 204, '204', over.postStatus) + if (over.postStatus !== 200 && over.postStatus !== 204) { + 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 @@ -1351,11 +1360,14 @@ export async function step13_cancel_rollback({ ctx, step, report }: StepCtx) { if (!api || ctx.supplies.length < 1) { step.status = 'skip'; return } const s1 = ctx.supplies[0] - // отмена проведения приёмки с уже проданным товаром → понятный отказ + // отмена проведения приёмки с уже проданным товаром → ПРОВЕДЕНО (минус разрешён), + // остаток может уйти в минус, появляется предупреждение. Затем вернём обратно (re-post). const unpost = await api.post(`/api/purchases/supplies/${s1.id}/unpost`, {}) - const refused = unpost.status >= 400 - rec(step, 'api', 'Отмена приёмки с проданным товаром → отказ', refused, '4xx', unpost.status) - if (!refused) report.bug({ step: '13', severity: 'high', title: 'Отмена приёмки с проданным товаром прошла (остаток ушёл в минус?)', detail: `unpost=${unpost.status}` }) + const ok = unpost.status === 200 || unpost.status === 204 + rec(step, 'api', 'Отмена приёмки с проданным товаром → проведено (минус разрешён)', ok, '204', 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) diff --git a/tests/food-market.IntegrationTests/NegativeStockTests.cs b/tests/food-market.IntegrationTests/NegativeStockTests.cs new file mode 100644 index 0000000..38f7db2 --- /dev/null +++ b/tests/food-market.IntegrationTests/NegativeStockTests.cs @@ -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()).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()).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(); + 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()).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); + } +}