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>
197 lines
9.8 KiB
C#
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 };
|
|
}
|
|
}
|