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>> 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 { Items = items, Total = total, Page = page, PageSize = pageSize }; } /// Sprint 19: экспорт остатков. [HttpGet("stock/export")] public async Task 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); /// 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 }; } }