Остатки строились ОТ таблицы 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>
187 lines
9.4 KiB
C#
187 lines
9.4 KiB
C#
using foodmarket.Infrastructure.Persistence;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace foodmarket.Api.Controllers.Reports;
|
||
|
||
/// <summary>Отчёт «Остатки на дату».
|
||
///
|
||
/// Восстанавливает остаток (Product, Store) на произвольный момент,
|
||
/// агрегируя журнал <see cref="Domain.Inventory.StockMovement"/> до этой даты.
|
||
/// Текущий <see cref="Domain.Inventory.Stock"/> является результатом
|
||
/// Σ движений (инвариант учёта), поэтому подсчёт на «сейчас» совпадает с
|
||
/// материализованным остатком; подсчёт на прошлую дату — реконструкция.
|
||
///
|
||
/// Edge-cases:
|
||
/// • Дата в будущем — возвращаем текущий остаток (Σ до сейчас): движений
|
||
/// из будущего нет, фильтр на это пропускает.
|
||
/// • Дата раньше первой операции — возвращаем нули (или вообще не отдаём
|
||
/// строку, потому что и таких пар (Product, Store) не было).
|
||
///
|
||
/// Стоимость остатка считаем по последнему известному `UnitCost` движения
|
||
/// до этой даты ИЛИ снимаем с `Product.Cost` если в движениях не было ни
|
||
/// одной партии (например, оприходование без UnitCost). Это компромисс
|
||
/// для приближённой оценки — точная FIFO/LIFO потребует партий, которые
|
||
/// в текущем учёте не ведутся.</summary>
|
||
[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<ActionResult<IReadOnlyList<StockRow>>> 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<IActionResult> 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),
|
||
};
|
||
}
|
||
|
||
/// <summary>Конвертит DateTime в UTC: ASP.NET парсит ISO-даты с Kind=Unspecified,
|
||
/// а Npgsql отказывается слать такие в timestamp with time zone.</summary>
|
||
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),
|
||
};
|
||
|
||
/// <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);
|
||
}
|
||
|
||
private async Task<List<StockRow>> 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<Guid>? 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();
|
||
}
|
||
}
|