food-market/src/food-market.api/Controllers/Inventory/StockController.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

197 lines
9.8 KiB
C#

using foodmarket.Application.Common;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Controllers.Reports;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
[ApiController]
[Authorize]
[Route("api/inventory")]
public class StockController : ControllerBase
{
private readonly AppDbContext _db;
public StockController(AppDbContext db) => _db = db;
public record StockRow(
Guid ProductId, string ProductName, string? Article, string UnitSymbol,
Guid StoreId, string StoreName,
decimal Quantity, decimal ReservedQuantity, decimal Available);
[HttpGet("stock")]
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
[FromQuery] Guid? storeId,
[FromQuery] Guid? productId,
[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,
[FromQuery] string? order = null,
CancellationToken ct = default)
{
var q = from s in _db.Stocks
join p in _db.Products on s.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
join st in _db.Stores on s.StoreId equals st.Id
select new { s, p, u, st };
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value);
if (groupId.HasValue)
{
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
q = q.Where(x => groupIds.Contains(x.p.ProductGroupId));
}
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()}%";
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
}
var total = await q.CountAsync(ct);
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
q = (sort, desc) switch
{
("article", false) => q.OrderBy(x => x.p.Article).ThenBy(x => x.p.Name),
("article", true) => q.OrderByDescending(x => x.p.Article).ThenBy(x => x.p.Name),
("unit", false) => q.OrderBy(x => x.u.Name).ThenBy(x => x.p.Name),
("unit", true) => q.OrderByDescending(x => x.u.Name).ThenBy(x => x.p.Name),
("store", false) => q.OrderBy(x => x.st.Name).ThenBy(x => x.p.Name),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenBy(x => x.p.Name),
("quantity", false) => q.OrderBy(x => x.s.Quantity).ThenBy(x => x.p.Name),
("quantity", true) => q.OrderByDescending(x => x.s.Quantity).ThenBy(x => x.p.Name),
("reserved", false) => q.OrderBy(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("reserved", true) => q.OrderByDescending(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("available", false) => q.OrderBy(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("available", true) => q.OrderByDescending(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name),
("name", true) => q.OrderByDescending(x => x.p.Name),
_ => q.OrderBy(x => x.p.Name),
};
var items = await q
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new StockRow(
x.p.Id, x.p.Name, x.p.Article, x.u.Name,
x.st.Id, x.st.Name,
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct);
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
/// <summary>Sprint 19: экспорт остатков.</summary>
[HttpGet("stock/export")]
public async Task<IActionResult> ExportStock(
[FromQuery] Guid? storeId, [FromQuery] string? search,
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
CancellationToken ct = default)
{
var q = from s in _db.Stocks
join p in _db.Products on s.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
join st in _db.Stores on s.StoreId equals st.Id
select new { s, p, u, st };
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
if (!string.IsNullOrWhiteSpace(search))
{
var term = $"%{search.Trim()}%";
q = q.Where(x => EF.Functions.ILike(x.p.Name, term)
|| (x.p.Article != null && EF.Functions.ILike(x.p.Article, term)));
}
var rows = await q.OrderBy(x => x.p.Name)
.Select(x => new StockExportRow(
x.p.Name, x.p.Article, x.u.Name, x.st.Name,
x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity))
.ToListAsync(ct);
var headers = new[] { "Товар", "Артикул", "Ед.", "Склад", "Количество", "Резерв", "Доступно" };
return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase)
? ReportExport.Xlsx(rows, "stock", "Остатки", headers)
: ReportExport.Csv(rows, "stock", headers);
}
public record StockExportRow(string Name, string? Article, string Unit, string Store,
decimal Quantity, decimal Reserved, decimal Available);
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному
/// Path). Пустой список, если группа не найдена → фильтр вернёт пусто.</summary>
private async Task<List<Guid>> DescendantGroupIdsAsync(Guid groupId, CancellationToken ct)
{
var path = await _db.ProductGroups.Where(g => g.Id == groupId)
.Select(g => g.Path).FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(path))
return await _db.ProductGroups.Where(g => g.Id == groupId).Select(g => g.Id).ToListAsync(ct);
return await _db.ProductGroups
.Where(g => g.Path == path || g.Path.StartsWith(path + "/"))
.Select(g => g.Id).ToListAsync(ct);
}
public record MovementRow(
Guid Id, DateTime OccurredAt,
Guid ProductId, string ProductName, string? Article,
Guid StoreId, string StoreName,
decimal Quantity, decimal? UnitCost,
string Type, string DocumentType, Guid? DocumentId, string? DocumentNumber,
string? Notes);
[HttpGet("movements")]
public async Task<ActionResult<PagedResult<MovementRow>>> GetMovements(
[FromQuery] Guid? storeId,
[FromQuery] Guid? productId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? sort = null,
[FromQuery] string? order = null,
CancellationToken ct = default)
{
var q = from m in _db.StockMovements
join p in _db.Products on m.ProductId equals p.Id
join st in _db.Stores on m.StoreId equals st.Id
select new { m, p, st };
if (storeId.HasValue) q = q.Where(x => x.m.StoreId == storeId.Value);
if (productId.HasValue) q = q.Where(x => x.m.ProductId == productId.Value);
if (from.HasValue) q = q.Where(x => x.m.OccurredAt >= from.Value);
if (to.HasValue) q = q.Where(x => x.m.OccurredAt < to.Value);
var total = await q.CountAsync(ct);
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
q = (sort, desc) switch
{
("occurredAt", false) => q.OrderBy(x => x.m.OccurredAt),
("occurredAt", true) => q.OrderByDescending(x => x.m.OccurredAt),
("product", false) => q.OrderBy(x => x.p.Name),
("product", true) => q.OrderByDescending(x => x.p.Name),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt),
("quantity", false) => q.OrderBy(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt),
("quantity", true) => q.OrderByDescending(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt),
("type", false) => q.OrderBy(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt),
("type", true) => q.OrderByDescending(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt),
("documentType", false) => q.OrderBy(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt),
("documentType", true) => q.OrderByDescending(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt),
_ => q.OrderByDescending(x => x.m.OccurredAt),
};
var items = await q
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new MovementRow(
x.m.Id, x.m.OccurredAt,
x.p.Id, x.p.Name, x.p.Article,
x.st.Id, x.st.Name,
x.m.Quantity, x.m.UnitCost,
x.m.Type.ToString(), x.m.DocumentType, x.m.DocumentId, x.m.DocumentNumber,
x.m.Notes))
.ToListAsync(ct);
return new PagedResult<MovementRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
}