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>> 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 { Items = items, Total = total, Page = page, PageSize = pageSize }; } /// Выборка товаров под список остатков: ОТ Products (не-услуги, не-архивные), /// фильтры по складу/группе/поиску и фильтр нуль/негатив — коррелированным подзапросом /// по остатку (товар без строки Stock = 0, поэтому нулевые тоже попадают). Возвращаем /// IQueryable<Product> — остаток/ед./штрихкод дотягиваем в памяти по странице, /// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY). private async Task> 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; } /// Сортировка списка остатков. По количеству/резерву/доступному — /// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит). private IQueryable OrderProducts( IQueryable products, string? sort, bool desc, Guid? storeId) { var sid = storeId ?? Guid.Empty; System.Linq.Expressions.Expression> 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> 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> 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> 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, "Все склады"); /// Остаток (кол-во/резерв) по товарам: один склад — его строка; без склада — /// сумма по складам. Нет строки Stock → товара нет в словаре (трактуем как 0). private async Task> StockByProductAsync( IReadOnlyCollection 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> UnitNamesAsync(IReadOnlyCollection 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); } /// Основной штрихкод (IsPrimary → первый) для набора товаров — одним /// запросом, чтобы не тащить коррелированный подзапрос в основную выборку. private async Task> BarcodesByProductAsync(IReadOnlyCollection 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()); } /// Sprint 19: экспорт остатков (те же фильтры, что и список; включая нулевые). [HttpGet("stock/export")] public async Task 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); /// Id группы + всех её вложенных подгрупп (по денормализованному /// Path). Пустой список, если группа не найдена → фильтр вернёт пусто. private async Task> 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>> 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 { Items = items, Total = total, Page = page, PageSize = pageSize }; } }