food-market/src/food-market.api/Controllers/Inventory/StockController.cs
nns 193ba49ecb fix(stock): «показать нулевые» и фильтр по группе показывают товары без прихода
Остатки строились ОТ таблицы Stocks (JOIN Products), поэтому товары без строки
Stock (ни разу не приходованные) не попадали в список вообще, а includeZero лишь
снимал фильтр Quantity!=0 у существующих строк. То же в отчёте «Остатки на дату»,
в экспорте и в фильтре по группе (iOS получал total 0).

- GetStock/ExportStock: выборка ОТ Products (не-услуги, не-архивные) с остатком
  коррелированным подзапросом (товар без Stock = 0). Склад выбран → строка склада;
  без склада → агрегат «Все склады». Нулевые видны при includeZero; negativeOnly и
  пагинация/сортировка/поиск/экспорт работают и для нулевых. Остаток/ед./штрихкод
  дотягиваются batch-запросом по id страницы (EF8 не переводит подзапросы в SELECT+ORDER BY).
- Отчёт «Остатки на дату»: includeZero добавляет товары без движений (Qty 0);
  фильтр по группе теперь с вложенностью.
- groupId/productGroupId — синонимы с вложенными группами на всех списках остатков
  и в каталоге (List + Export). Раньше catalog?productGroupId молча игнорировался.
- Штрихкод рядом с артикулом в списке/экспорте — различать одноимённые товары
  (4× «BIOBLAS шампунь» с разными артикулами из МойСклада — не удалять).
- Demo (30 894 товара): без флага 12, includeZero → 30 894, <500мс/страница.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 09:44:50 +05:00

284 lines
17 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;
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? Barcode, 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] Guid? productGroupId,
[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 products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, includeZero, negativeOnly, ct);
var total = await products.CountAsync(ct);
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
var ordered = OrderProducts(products, sort, desc, storeId);
var paged = await ordered.Skip((page - 1) * pageSize).Take(pageSize)
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
var (sid, storeName) = await StoreContextAsync(storeId, ct);
var ids = paged.Select(x => x.Id).ToList();
var stock = await StockByProductAsync(ids, storeId, ct);
var barcodes = await BarcodesByProductAsync(ids, ct);
var units = await UnitNamesAsync(paged.Select(x => x.UnitOfMeasureId).ToList(), ct);
var items = paged.Select(x =>
{
var (qty, reserved) = stock.GetValueOrDefault(x.Id);
return new StockRow(
x.Id, x.Name, x.Article, barcodes.GetValueOrDefault(x.Id),
units.GetValueOrDefault(x.UnitOfMeasureId, ""),
sid, storeName, qty, reserved, qty - reserved);
}).ToList();
return new PagedResult<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
}
/// <summary>Выборка товаров под список остатков: ОТ Products (не-услуги, не-архивные),
/// фильтры по складу/группе/поиску и фильтр нуль/негатив — коррелированным подзапросом
/// по остатку (товар без строки Stock = 0, поэтому нулевые тоже попадают). Возвращаем
/// IQueryable&lt;Product&gt; — остаток/ед./штрихкод дотягиваем в памяти по странице,
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
private async Task<IQueryable<Domain.Catalog.Product>> FilteredProductsAsync(
Guid? storeId, Guid? productId, Guid? groupId, string? search,
bool includeZero, bool negativeOnly, CancellationToken ct)
{
var products = _db.Products.Where(p => !p.IsService && !p.IsArchived);
if (productId.HasValue) products = products.Where(p => p.Id == productId.Value);
if (groupId.HasValue)
{
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
products = products.Where(p => groupIds.Contains(p.ProductGroupId));
}
if (!string.IsNullOrWhiteSpace(search))
{
var term = $"%{search.Trim()}%";
products = products.Where(p => EF.Functions.ILike(p.Name, term)
|| (p.Article != null && EF.Functions.ILike(p.Article, term))
|| p.Barcodes.Any(b => EF.Functions.ILike(b.Code, term)));
}
// Остаток товара как скалярный подзапрос с COALESCE(...,0). Фильтр по нему в WHERE
// EF8 переводит (ORDER BY/SELECT по тому же выражению — нет, поэтому там in-memory).
var sid = storeId ?? Guid.Empty;
if (storeId.HasValue)
{
if (negativeOnly)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m);
else if (!includeZero)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) != 0m);
}
else
{
if (negativeOnly)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m);
else if (!includeZero)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) != 0m);
}
return products;
}
/// <summary>Сортировка списка остатков. По количеству/резерву/доступному —
/// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит).</summary>
private IQueryable<Domain.Catalog.Product> OrderProducts(
IQueryable<Domain.Catalog.Product> products, string? sort, bool desc, Guid? storeId)
{
var sid = storeId ?? Guid.Empty;
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, decimal>> qty = storeId.HasValue
? (p => _db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m)
: (p => _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m);
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, decimal>> reserved = storeId.HasValue
? (p => _db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.ReservedQuantity).FirstOrDefault() ?? 0m)
: (p => _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.ReservedQuantity) ?? 0m);
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, decimal>> avail = storeId.HasValue
? (p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m)
- (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.ReservedQuantity).FirstOrDefault() ?? 0m))
: (p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m)
- (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.ReservedQuantity) ?? 0m));
System.Linq.Expressions.Expression<Func<Domain.Catalog.Product, string?>> unit =
p => _db.UnitsOfMeasure.Where(u => u.Id == p.UnitOfMeasureId).Select(u => u.Name).FirstOrDefault();
return sort switch
{
"article" => desc ? products.OrderByDescending(p => p.Article).ThenBy(p => p.Name) : products.OrderBy(p => p.Article).ThenBy(p => p.Name),
"unit" => desc ? products.OrderByDescending(unit).ThenBy(p => p.Name) : products.OrderBy(unit).ThenBy(p => p.Name),
"quantity" => desc ? products.OrderByDescending(qty).ThenBy(p => p.Name) : products.OrderBy(qty).ThenBy(p => p.Name),
"reserved" => desc ? products.OrderByDescending(reserved).ThenBy(p => p.Name) : products.OrderBy(reserved).ThenBy(p => p.Name),
"available" => desc ? products.OrderByDescending(avail).ThenBy(p => p.Name) : products.OrderBy(avail).ThenBy(p => p.Name),
"store" => products.OrderBy(p => p.Name), // один склад или агрегат «все склады»
"name" when desc => products.OrderByDescending(p => p.Name),
_ => products.OrderBy(p => p.Name),
};
}
private async Task<(Guid Sid, string StoreName)> StoreContextAsync(Guid? storeId, CancellationToken ct)
=> storeId.HasValue
? (storeId.Value, await _db.Stores.Where(s => s.Id == storeId.Value).Select(s => s.Name).FirstOrDefaultAsync(ct) ?? "")
: (Guid.Empty, "Все склады");
/// <summary>Остаток (кол-во/резерв) по товарам: один склад — его строка; без склада —
/// сумма по складам. Нет строки Stock → товара нет в словаре (трактуем как 0).</summary>
private async Task<Dictionary<Guid, (decimal Qty, decimal Reserved)>> StockByProductAsync(
IReadOnlyCollection<Guid> ids, Guid? storeId, CancellationToken ct)
{
if (ids.Count == 0) return new();
var sq = _db.Stocks.Where(s => ids.Contains(s.ProductId));
if (storeId.HasValue) sq = sq.Where(s => s.StoreId == storeId.Value);
var raw = await sq.Select(s => new { s.ProductId, s.Quantity, s.ReservedQuantity }).ToListAsync(ct);
return raw.GroupBy(x => x.ProductId)
.ToDictionary(g => g.Key, g => (g.Sum(x => x.Quantity), g.Sum(x => x.ReservedQuantity)));
}
private async Task<Dictionary<Guid, string>> UnitNamesAsync(IReadOnlyCollection<Guid> unitIds, CancellationToken ct)
{
if (unitIds.Count == 0) return new();
return await _db.UnitsOfMeasure.Where(u => unitIds.Contains(u.Id))
.Select(u => new { u.Id, u.Name }).ToDictionaryAsync(u => u.Id, u => u.Name, ct);
}
/// <summary>Основной штрихкод (IsPrimary → первый) для набора товаров — одним
/// запросом, чтобы не тащить коррелированный подзапрос в основную выборку.</summary>
private async Task<Dictionary<Guid, string>> BarcodesByProductAsync(IReadOnlyCollection<Guid> productIds, CancellationToken ct)
{
if (productIds.Count == 0) return new();
var raw = await _db.ProductBarcodes
.Where(b => productIds.Contains(b.ProductId))
.Select(b => new { b.ProductId, b.Code, b.IsPrimary })
.ToListAsync(ct);
return raw.GroupBy(b => b.ProductId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).First());
}
/// <summary>Sprint 19: экспорт остатков (те же фильтры, что и список; включая нулевые).</summary>
[HttpGet("stock/export")]
public async Task<IActionResult> ExportStock(
[FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId,
[FromQuery] string? search,
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
CancellationToken ct = default)
{
var products = await FilteredProductsAsync(storeId, null, groupId ?? productGroupId, search, includeZero, false, ct);
var paged = await products.OrderBy(p => p.Name)
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
var (_, storeName) = await StoreContextAsync(storeId, ct);
var ids = paged.Select(x => x.Id).ToList();
var stock = await StockByProductAsync(ids, storeId, ct);
var barcodes = await BarcodesByProductAsync(ids, ct);
var units = await UnitNamesAsync(paged.Select(x => x.UnitOfMeasureId).ToList(), ct);
var rows = paged.Select(x =>
{
var (qty, reserved) = stock.GetValueOrDefault(x.Id);
return new StockExportRow(x.Name, x.Article, barcodes.GetValueOrDefault(x.Id),
units.GetValueOrDefault(x.UnitOfMeasureId, ""), storeName, qty, reserved, qty - reserved);
}).ToList();
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? Barcode, 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 };
}
}