food-market/src/food-market.infrastructure/Inventory/NegativeStockAlertService.cs
nns 9ba1edb236
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
feat(stock): разрешение продажи в минус + предупреждения об отрицательных остатках
Решение владельца 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>
2026-10-09 02:18:29 +05:00

79 lines
3.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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