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();
}
}