feat: add ItemOverviewDto for enriched item data in items list
feat: implement CustomerDetailPage for detailed customer view and editing feat: create CustomersPage for listing and managing customers with filters feat: add ColumnsMenu component for customizable table columns visibility feat: implement CSV export functionality for data download feat: add customer code generation logic for unique customer identifiers
This commit is contained in:
@@ -100,7 +100,7 @@ public sealed class GrnService : IGrnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new GrnSummaryDto(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
|
||||
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ public interface IItemService
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Same filters as <see cref="ListAsync"/>, enriched with stock value, low-stock,
|
||||
/// last-purchase and trailing-30-day sales aggregates for the Items list page.</summary>
|
||||
Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>> CreateAsync(CreateItemRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
@@ -35,6 +36,7 @@ public sealed class ItemService : IItemService
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<StockLayer> _stockLayers;
|
||||
private readonly IRepository<StockLedger> _stockLedger;
|
||||
private readonly IRepository<GrnLine> _grnLines;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly IProductConfigService _config;
|
||||
@@ -52,6 +54,7 @@ public sealed class ItemService : IItemService
|
||||
IRepository<Warehouse> warehouses,
|
||||
IRepository<StockLayer> stockLayers,
|
||||
IRepository<StockLedger> stockLedger,
|
||||
IRepository<GrnLine> grnLines,
|
||||
IRepository<Batch> batches,
|
||||
IRepository<Serial> serials,
|
||||
IProductConfigService config,
|
||||
@@ -68,6 +71,7 @@ public sealed class ItemService : IItemService
|
||||
_warehouses = warehouses;
|
||||
_stockLayers = stockLayers;
|
||||
_stockLedger = stockLedger;
|
||||
_grnLines = grnLines;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_config = config;
|
||||
@@ -108,6 +112,82 @@ public sealed class ItemService : IItemService
|
||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ItemOverviewDto>> GetOverviewAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _items.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(i => EF.Functions.ILike(i.Sku, $"%{term}%") || EF.Functions.ILike(i.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(i => i.Status == status);
|
||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var page = await q.OrderBy(i => i.Sku)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(i => new { i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status, i.ReorderSettings })
|
||||
.ToListAsync(ct);
|
||||
var itemIds = page.Select(i => i.ItemId).ToList();
|
||||
|
||||
// Current FIFO stock value/qty — every open layer, every warehouse.
|
||||
var stockByItem = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyRemaining), Value = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.ToDictionaryAsync(x => x.ItemId, ct);
|
||||
|
||||
// On-hand per (item, warehouse), to compare against each item's reorder settings.
|
||||
var onHandByItemWarehouse = (await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.QtyRemaining > 0)
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
|
||||
|
||||
// Last confirmed purchase (GRN) per item, with its vendor — pulled in bulk and
|
||||
// reduced in memory rather than a "first per group" query EF can't always translate.
|
||||
var grnCandidates = await _grnLines.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.Grn!.PostedAt != null)
|
||||
.Select(l => new { l.ItemId, PostedAt = l.Grn!.PostedAt!.Value, l.Grn.VendorId, VendorName = l.Grn.Vendor!.Name })
|
||||
.ToListAsync(ct);
|
||||
var lastPurchaseByItem = grnCandidates
|
||||
.GroupBy(x => x.ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.PostedAt).First());
|
||||
|
||||
// Qty sold in the trailing 30 days, across every sale channel (invoice/slip/bundle),
|
||||
// read off the stock ledger — the one place every sale channel posts uniformly.
|
||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||
var salesTypes = new[] { DocumentTypes.SalesInvoice, DocumentTypes.SalesSlip, DocumentTypes.BundleSale };
|
||||
var salesByItem = await _stockLedger.Query().AsNoTracking()
|
||||
.Where(l => itemIds.Contains(l.ItemId) && l.Direction == Direction.Out
|
||||
&& l.CreatedAt >= cutoff && salesTypes.Contains(l.SourceDocType))
|
||||
.GroupBy(l => l.ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.QtyBase) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Qty, ct);
|
||||
|
||||
var rows = page.Select(i =>
|
||||
{
|
||||
var stock = stockByItem.GetValueOrDefault(i.ItemId);
|
||||
var isLowStock = i.ReorderSettings.Any(rs =>
|
||||
onHandByItemWarehouse.GetValueOrDefault((rs.ItemId, rs.WarehouseId)) <= rs.ReorderPoint);
|
||||
var lastPurchase = lastPurchaseByItem.GetValueOrDefault(i.ItemId);
|
||||
|
||||
return new ItemOverviewDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.Status,
|
||||
stock?.Qty ?? 0m, stock?.Value ?? 0m, isLowStock,
|
||||
lastPurchase?.PostedAt, lastPurchase?.VendorId, lastPurchase?.VendorName,
|
||||
salesByItem.GetValueOrDefault(i.ItemId));
|
||||
}).ToList();
|
||||
|
||||
return PagedResponse<ItemOverviewDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking()
|
||||
|
||||
Reference in New Issue
Block a user