Остатки строились ОТ таблицы 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>
284 lines
17 KiB
C#
284 lines
17 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? 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<Product> — остаток/ед./штрихкод дотягиваем в памяти по странице,
|
||
/// чтобы не тащить подзапросы в 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 };
|
||
}
|
||
}
|