food-market/src/food-market.infrastructure/Inventory/StockService.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

76 lines
2.6 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;
public class StockService : IStockService
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly INegativeStockAlertService _negAlerts;
public StockService(AppDbContext db, ITenantContext tenant, INegativeStockAlertService negAlerts)
{
_db = db;
_tenant = tenant;
_negAlerts = negAlerts;
}
public async Task<decimal> ApplyMovementAsync(StockMovementDraft d, CancellationToken ct = default)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set.");
_db.StockMovements.Add(new StockMovement
{
OrganizationId = orgId,
ProductId = d.ProductId,
StoreId = d.StoreId,
Quantity = d.Quantity,
UnitCost = d.UnitCost,
Type = d.Type,
DocumentType = d.DocumentType,
DocumentId = d.DocumentId,
DocumentNumber = d.DocumentNumber,
OccurredAt = d.OccurredAt ?? DateTime.UtcNow,
CreatedByUserId = d.CreatedByUserId,
Notes = d.Notes,
});
var stock = await _db.Stocks.FirstOrDefaultAsync(
s => s.ProductId == d.ProductId && s.StoreId == d.StoreId, ct);
if (stock is null)
{
stock = new Stock
{
OrganizationId = orgId,
ProductId = d.ProductId,
StoreId = d.StoreId,
Quantity = d.Quantity,
};
_db.Stocks.Add(stock);
}
else
{
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;
}
public async Task<decimal> ApplyMovementsAsync(IEnumerable<StockMovementDraft> drafts, CancellationToken ct = default)
{
var last = 0m;
foreach (var d in drafts) last = await ApplyMovementAsync(d, ct);
return last;
}
}