Complete all for Items

This commit is contained in:
2026-07-17 14:27:51 +05:30
parent f72b24fcaa
commit 62a5d857de
103 changed files with 2540 additions and 3259 deletions
+54 -8
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count))
.ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default)
{
var adjustment = await _adjustments.Query().AsNoTracking()
.Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
if (adjustment is null) return null;
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(adjustment, ledgerRefs);
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService
return (entity, refs);
}, ct);
return new AdjustmentDto(
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
adjustment.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList());
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs);
}
@@ -55,8 +55,11 @@ public sealed class AuthUserService : IAuthUserService
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
/// <summary>The controller resolves the id (from body or token claim) before calling here.</summary>
public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default)
=> _authHex.LogoutUserAsync(request.UserId, ct);
=> request.UserId is null
? Task.CompletedTask
: _authHex.LogoutUserAsync(request.UserId.Value, ct);
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
+26 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -49,6 +50,30 @@ public sealed class CountService : ICountService
_uow = uow;
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
{
var q = _counts.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
return PagedResponse<CountSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
@@ -175,7 +200,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+27
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService
_uow = uow;
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(g => g.Status == status);
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
public interface ICountService
{
Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default);
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
public interface IGrnService
{
Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
public interface IPurchaseReturnService
{
Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default);
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
public interface IRequisitionService
{
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(
PageQuery query, RequisitionStatus? status, CancellationToken ct = default);
Task<RequisitionDto?> GetAsync(int requisitionId, CancellationToken ct = default);
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
Task<RequisitionDto> SubmitAsync(int requisitionId, CancellationToken ct = default);
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces;
/// <summary>RFQ &amp; vendor-quotation business logic (docs/11 §3.2).</summary>
public interface IRfqService
{
Task<PagedResponse<RfqSummaryDto>> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default);
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
Task<VendorQuotationDto> AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default);
@@ -8,8 +8,13 @@ public interface IStockService
{
Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
public interface ITransferService
{
Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default);
Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
+6
View File
@@ -89,6 +89,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
@@ -131,6 +132,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
@@ -351,5 +353,9 @@ public sealed class ItemService : IItemService
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_vendors = vendors;
@@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _returns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.ReturnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new PurchaseReturnSummaryDto(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
r.CreatedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{
var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null;
// Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(ret, ledgerRefs);
}
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
}, ct);
// Map ledger ids after commit so they are populated.
return new PurchaseReturnDto(
entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status,
entity.CreatedBy,
entity.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerEntries.Select(r => r.LedgerId).ToList());
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
}
private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerRefs);
}
@@ -31,7 +31,8 @@ public sealed class RequisitionService : IRequisitionService
_uow = uow;
}
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(
PageQuery query, RequisitionStatus? status, CancellationToken ct = default)
{
var q = _requisitions.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.RequisitionId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt))
.Select(r => new RequisitionSummaryDto(
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
+26
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
@@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService
_uow = uow;
}
public async Task<PagedResponse<RfqSummaryDto>> ListAsync(
PageQuery query, RfqStatus? status, CancellationToken ct = default)
{
var q = _rfqs.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.RfqId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RfqSummaryDto(
r.RfqId, r.DocNo, r.RequisitionId, r.Status,
r.Lines.Count,
// Correlated subquery: there is no Rfq.Quotations navigation to count.
_quotations.Query().Count(qt => qt.RfqId == r.RfqId)))
.ToListAsync(ct);
return PagedResponse<RfqSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default)
{
var rfq = await _rfqs.Query().AsNoTracking()
+72 -1
View File
@@ -50,14 +50,85 @@ public sealed class StockService : IStockService
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
}
/// <summary>
/// On-hand across every (item, warehouse) pair that holds stock — backs the Stock
/// Enquiry list. Deliberately set-based: four grouped queries regardless of page size,
/// rather than calling <see cref="GetOnHandAsync"/> per row (which would be N+1).
/// Pairs are sourced from <c>StockLayer</c>, so an item that never had a receipt in a
/// warehouse simply does not appear.
/// </summary>
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default)
{
var layers = _layers.Query().AsNoTracking();
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
var grouped = layers
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, OnHand = g.Sum(x => x.QtyRemaining) });
var total = await grouped.CountAsync(ct);
var page = await grouped
.OrderBy(x => x.ItemId).ThenBy(x => x.WarehouseId)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
if (page.Count == 0)
return PagedResponse<StockOnHandDto>.Create([], query.Page, query.PageSize, total);
// Filtering by the page's ids gives a superset (the cross-product of both lists);
// the join below narrows it back to the actual pairs.
var itemIds = page.Select(p => p.ItemId).Distinct().ToList();
var warehouseIds = page.Select(p => p.WarehouseId).Distinct().ToList();
var onHold = (await _layers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId)
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
.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);
var inTransit = (await _transferLines.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId)
&& l.Transfer != null
&& warehouseIds.Contains(l.Transfer.SrcWarehouseId)
&& l.Transfer.Status == TransferStatus.InTransit)
.GroupBy(l => new { l.ItemId, WarehouseId = l.Transfer!.SrcWarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.Qty - x.QtyReceived) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
var asOf = DateTime.UtcNow;
var rows = page.Select(p =>
{
var key = (p.ItemId, p.WarehouseId);
var hold = onHold.GetValueOrDefault(key);
var transit = inTransit.GetValueOrDefault(key);
const decimal reserved = 0m;
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
return new StockOnHandDto(
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf);
}).ToList();
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
{
var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
// Source-doc filter: the ledger references its originating document polymorphically
// (docs/10 C.9), so this is the only way to ask "what did document X post?" —
// needed by any screen that reports on a document's costed movements.
if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(l => l.SourceDocType == sourceDocType);
if (sourceDocId is not null) q = q.Where(l => l.SourceDocId == sourceDocId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LedgerId)
+27 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService
_uow = uow;
}
public async Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default)
{
var q = _transfers.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new TransferSummaryDto(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.CreatedBy, t.CreatedAt, t.Lines.Count))
.ToListAsync(ct);
return PagedResponse<TransferSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default)
{
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
@@ -190,7 +216,7 @@ public sealed class TransferService : ITransferService
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
}