food-market/src/food-market.api/Controllers/Reports/StockReportController.cs
nns 193ba49ecb fix(stock): «показать нулевые» и фильтр по группе показывают товары без прихода
Остатки строились ОТ таблицы 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>
2026-10-09 09:44:50 +05:00

187 lines
9.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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