Complete all for Items
This commit is contained in:
@@ -125,16 +125,45 @@ public sealed class AuthController : ControllerBase
|
||||
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
|
||||
|
||||
/// <summary>
|
||||
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
|
||||
/// <para>
|
||||
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
|
||||
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
|
||||
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cookies are cleared even if the upstream revoke fails or no user can be
|
||||
/// resolved: a logout that leaves the caller holding a live session cookie is worse
|
||||
/// than one that leaves a stale session server-side (which lapses on its own).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
|
||||
{
|
||||
await _users.LogoutUserAsync(request, ct);
|
||||
var userId = request?.UserId ?? ResolveTokenUserId();
|
||||
if (userId is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
|
||||
}
|
||||
catch (DomainException)
|
||||
{
|
||||
// Upstream unreachable or already-revoked — fall through and clear anyway.
|
||||
}
|
||||
}
|
||||
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
|
||||
private Guid? ResolveTokenUserId()
|
||||
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
|
||||
|
||||
[HttpPut("me")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase
|
||||
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase
|
||||
|
||||
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
||||
|
||||
/// <summary>List posted returns, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
|
||||
|
||||
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{returnId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.GetAsync(returnId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _requisitions.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
|
||||
=> Ok(await _requisitions.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{requisitionId:int}")]
|
||||
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase
|
||||
|
||||
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
|
||||
|
||||
/// <summary>List RFQs, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
|
||||
=> Ok(await _rfqs.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{rfqId:int}")]
|
||||
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
|
||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||
|
||||
/// <summary>List posted adjustments, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
|
||||
|
||||
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{adjustmentId:int}")]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _adjustments.GetAsync(adjustmentId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||
|
||||
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
|
||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
|
||||
|
||||
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
|
||||
[HttpGet("on-hand/list")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
|
||||
|
||||
/// <summary>
|
||||
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
|
||||
/// this document post?" — the ledger's document reference is polymorphic, so there is
|
||||
/// no FK to navigate instead (docs/10 C.9).
|
||||
/// </summary>
|
||||
[HttpGet("ledger")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
|
||||
|
||||
[HttpGet("valuation")]
|
||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase
|
||||
|
||||
public StockCountsController(ICountService counts) => _counts = counts;
|
||||
|
||||
/// <summary>List counts, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
|
||||
|
||||
[HttpGet("{countId:int}")]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase
|
||||
|
||||
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
||||
|
||||
/// <summary>List transfers, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
|
||||
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
|
||||
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
|
||||
|
||||
[HttpGet("{transferId:int}")]
|
||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -175,5 +175,9 @@ public sealed class TwoFaStatusResponse
|
||||
|
||||
public sealed class LogoutRequest
|
||||
{
|
||||
[Required] public Guid UserId { get; set; }
|
||||
/// <summary>
|
||||
/// Optional: AuthHex returns no <c>userId</c> on login, so browsers cannot supply one.
|
||||
/// When omitted, the controller resolves it from the session token's UserId claim.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record GrnSummaryDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
@@ -14,12 +14,21 @@ public sealed record ItemListItemDto(
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
||||
|
||||
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
|
||||
/// <summary>
|
||||
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
|
||||
/// <para>
|
||||
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
|
||||
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
|
||||
/// endpoint reads them back — so a detail screen could never show current state before
|
||||
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ItemDetailDto(
|
||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||
StockNature StockNature, TrackingMode TrackingMode,
|
||||
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
IReadOnlyList<UomConversionDto> Conversions,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
|
||||
|
||||
@@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
|
||||
|
||||
public sealed record PurchaseReturnDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record PurchaseReturnSummaryDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
|
||||
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
||||
|
||||
public sealed record RequisitionSummaryDto(
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
|
||||
public sealed record RfqDto(
|
||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /rfqs</c> — line/quotation counts instead of the lines themselves.</summary>
|
||||
public sealed record RfqSummaryDto(
|
||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
|
||||
|
||||
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
|
||||
|
||||
public sealed record VendorQuotationDto(
|
||||
|
||||
@@ -11,6 +11,11 @@ public sealed record AdjustmentDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record AdjustmentSummaryDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateAdjustmentLineInput
|
||||
|
||||
@@ -8,7 +8,13 @@ namespace ERPCore.Dtos.Stock;
|
||||
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
|
||||
|
||||
public sealed record CountDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record CountSummaryDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
|
||||
|
||||
public sealed record TransferDto(
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
||||
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
|
||||
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record TransferSummaryDto(
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
||||
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 & 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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user