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>
79 lines
3.1 KiB
C#
79 lines
3.1 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|