using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers.Reports; /// Отчёт «Остатки на дату». /// /// Восстанавливает остаток (Product, Store) на произвольный момент, /// агрегируя журнал до этой даты. /// Текущий является результатом /// Σ движений (инвариант учёта), поэтому подсчёт на «сейчас» совпадает с /// материализованным остатком; подсчёт на прошлую дату — реконструкция. /// /// Edge-cases: /// • Дата в будущем — возвращаем текущий остаток (Σ до сейчас): движений /// из будущего нет, фильтр на это пропускает. /// • Дата раньше первой операции — возвращаем нули (или вообще не отдаём /// строку, потому что и таких пар (Product, Store) не было). /// /// Стоимость остатка считаем по последнему известному `UnitCost` движения /// до этой даты ИЛИ снимаем с `Product.Cost` если в движениях не было ни /// одной партии (например, оприходование без UnitCost). Это компромисс /// для приближённой оценки — точная FIFO/LIFO потребует партий, которые /// в текущем учёте не ведутся. [ApiController] [Authorize] [Route("api/reports/stock")] public class StockReportController : ControllerBase { private readonly AppDbContext _db; public StockReportController(AppDbContext db) => _db = db; public record StockRow( Guid ProductId, string ProductName, string? ProductArticle, string? UnitName, Guid StoreId, string StoreName, decimal Quantity, decimal Cost, decimal Value); [HttpGet] public async Task>> Get( [FromQuery] DateTime? date, [FromQuery] Guid? storeId, [FromQuery] Guid? productGroupId, [FromQuery] Guid? groupId, [FromQuery] bool includeZero = false, CancellationToken ct = default) { var on = AsUtc(date) ?? DateTime.UtcNow; return Ok(await BuildAsync(on, storeId, productGroupId ?? groupId, includeZero, ct)); } [HttpGet("export")] public async Task Export( [FromQuery] DateTime? date, [FromQuery] Guid? storeId, [FromQuery] Guid? productGroupId, [FromQuery] Guid? groupId, [FromQuery] bool includeZero = false, [FromQuery] string format = "csv", CancellationToken ct = default) { var on = AsUtc(date) ?? DateTime.UtcNow; var rows = await BuildAsync(on, storeId, productGroupId ?? groupId, includeZero, ct); var name = $"stock-{on:yyyyMMdd}"; var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" }; return format.ToLower() switch { "xlsx" => ReportExport.Xlsx(rows, name, "Stock", headers), _ => ReportExport.Csv(rows, name, headers), }; } /// Конвертит DateTime в UTC: ASP.NET парсит ISO-даты с Kind=Unspecified, /// а Npgsql отказывается слать такие в timestamp with time zone. private static DateTime? AsUtc(DateTime? d) => d?.Kind switch { null => null, DateTimeKind.Utc => d, DateTimeKind.Local => d.Value.ToUniversalTime(), _ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc), }; /// 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); } private async Task> BuildAsync( DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct) { // Σ Quantity и последний UnitCost (через max OccurredAt) на (Product, Store) // — два отдельных запроса; объединяем в памяти. Альтернатива через window // functions требует sql-raw; на типовых объёмах (десятки тыс. движений) // двойной round-trip с агрегатом дешевле и читабельнее. var movQ = _db.StockMovements.AsNoTracking().Where(m => m.OccurredAt <= on); if (storeId is not null) movQ = movQ.Where(m => m.StoreId == storeId); var qtyByPair = await movQ .GroupBy(m => new { m.ProductId, m.StoreId }) .Select(g => new { g.Key.ProductId, g.Key.StoreId, Qty = g.Sum(m => m.Quantity) }) .ToListAsync(ct); // Последний UnitCost (max OccurredAt с не-null UnitCost) на пару. var lastCostsRaw = await movQ .Where(m => m.UnitCost != null) .GroupBy(m => new { m.ProductId, m.StoreId }) .Select(g => new { g.Key.ProductId, g.Key.StoreId, LastCost = g.OrderByDescending(m => m.OccurredAt).Select(m => m.UnitCost).First(), }) .ToListAsync(ct); var lastCosts = lastCostsRaw .ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m); // Набор допустимых товаров (не-услуги; фильтр по группе с вложенностью). HashSet? allowedSet = null; if (productGroupId is not null) { var groupIds = await DescendantGroupIdsAsync(productGroupId.Value, ct); allowedSet = (await _db.Products.AsNoTracking() .Where(p => !p.IsService && !p.IsArchived && groupIds.Contains(p.ProductGroupId)) .Select(p => p.Id).ToListAsync(ct)).ToHashSet(); qtyByPair = qtyByPair.Where(x => allowedSet.Contains(x.ProductId)).ToList(); } if (!includeZero) { qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList(); } else { // «Показать нулевые»: добавляем товары без движений (ни разу не приходованы) — // строка с Qty 0. Склад выбран → его id; все склады → агрегат «Все склады». var seen = qtyByPair.Select(x => x.ProductId).ToHashSet(); var zeroQ = _db.Products.AsNoTracking().Where(p => !p.IsService && !p.IsArchived && !seen.Contains(p.Id)); if (allowedSet is not null) zeroQ = zeroQ.Where(p => allowedSet.Contains(p.Id)); var zeroStore = storeId ?? Guid.Empty; var zeroIds = await zeroQ.Select(p => p.Id).ToListAsync(ct); qtyByPair = qtyByPair.Concat( zeroIds.Select(id => new { ProductId = id, StoreId = zeroStore, Qty = 0m })).ToList(); } // Подтаскиваем имена товара/склада. var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList(); var storeIds = qtyByPair.Select(x => x.StoreId).Distinct().ToList(); var products = await _db.Products.AsNoTracking() .Where(p => productIds.Contains(p.Id)) .Join(_db.UnitsOfMeasure.AsNoTracking(), p => p.UnitOfMeasureId, u => u.Id, (p, u) => new { p.Id, p.Name, p.Article, p.Cost, UnitName = u.Name }) .ToListAsync(ct); var stores = await _db.Stores.AsNoTracking() .Where(s => storeIds.Contains(s.Id)) .Select(s => new { s.Id, s.Name }) .ToListAsync(ct); var pMap = products.ToDictionary(p => p.Id); var sMap = stores.ToDictionary(s => s.Id); return qtyByPair .Select(x => { pMap.TryGetValue(x.ProductId, out var p); sMap.TryGetValue(x.StoreId, out var s); lastCosts.TryGetValue((x.ProductId, x.StoreId), out var cost); if (cost == 0m && p is not null) cost = p.Cost; var storeName = x.StoreId == Guid.Empty ? "Все склады" : (s?.Name ?? "(unknown)"); return new StockRow( x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName, x.StoreId, storeName, x.Qty, cost, x.Qty * cost); }) .OrderBy(r => r.ProductName) .ThenBy(r => r.StoreName) .ToList(); } }