Merge pull request 'feat: add sales return functionality' (#40) from sales-return into Dev
Reviewed-on: #40
This commit was merged in pull request #40.
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
||||||
|
[Route("api/v1/sales-returns")]
|
||||||
|
public sealed class SalesReturnsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISalesReturnService _returns;
|
||||||
|
|
||||||
|
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
|
||||||
|
|
||||||
|
/// <summary>List posted returns, newest first.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
|
||||||
|
|
||||||
|
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
|
||||||
|
[HttpGet("remaining")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
|
||||||
|
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
|
||||||
|
|
||||||
|
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
|
||||||
|
[HttpGet("{returnId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SalesReturnDto>> 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 (inbound movement).</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _returns.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,4 +20,5 @@ public static class DocumentTypes
|
|||||||
public const string SalesInvoice = "SI";
|
public const string SalesInvoice = "SI";
|
||||||
public const string SalesSlip = "SSL";
|
public const string SalesSlip = "SSL";
|
||||||
public const string BundleSale = "BND";
|
public const string BundleSale = "BND";
|
||||||
|
public const string SalesReturn = "SRET";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sales return header — a customer returns previously sold goods, generating an
|
||||||
|
/// inbound stock movement. Auto-posts with a mandatory reason code, mirroring
|
||||||
|
/// <see cref="PurchaseReturn"/> with the direction reversed.
|
||||||
|
/// </summary>
|
||||||
|
public class SalesReturn
|
||||||
|
{
|
||||||
|
public int ReturnId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
public Customer? Customer { get; set; }
|
||||||
|
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public int ReasonCodeId { get; set; }
|
||||||
|
public ReasonCode? ReasonCode { get; set; }
|
||||||
|
|
||||||
|
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public User? Creator { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sales-return line referencing the original sales invoice line for traceability.
|
||||||
|
/// <see cref="Qty"/> is in base UOM.
|
||||||
|
/// </summary>
|
||||||
|
public class SalesReturnLine
|
||||||
|
{
|
||||||
|
public int ReturnLineId { get; set; }
|
||||||
|
|
||||||
|
public int ReturnId { get; set; }
|
||||||
|
public SalesReturn? Return { get; set; }
|
||||||
|
|
||||||
|
public int? SalesInvoiceLineId { get; set; }
|
||||||
|
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
// Responses -----------------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty);
|
||||||
|
|
||||||
|
public sealed record SalesReturnDto(
|
||||||
|
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||||
|
int CreatedBy, DateTime CreatedAt, IReadOnlyList<SalesReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||||
|
|
||||||
|
/// <summary>Row shape for <c>GET /sales-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||||
|
public sealed record SalesReturnSummaryDto(
|
||||||
|
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||||
|
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
|
||||||
|
/// whatever has already been returned against it. The invoice line's own <c>Qty</c>
|
||||||
|
/// is never mutated by a return, so this is computed on read from return history.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty);
|
||||||
|
|
||||||
|
// Requests --------------------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed class CreateSalesReturnLineInput
|
||||||
|
{
|
||||||
|
/// <summary>Original sales invoice line, for traceability against the sale.</summary>
|
||||||
|
public int? SalesInvoiceLineId { get; set; }
|
||||||
|
[Required] public int ItemId { get; set; }
|
||||||
|
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateSalesReturnRequest
|
||||||
|
{
|
||||||
|
[Required] public int CustomerId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
|
||||||
|
public int? ReasonCodeId { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class SalesReturnConfiguration : IEntityTypeConfiguration<SalesReturn>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesReturn> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("sales_returns");
|
||||||
|
builder.HasKey(r => r.ReturnId);
|
||||||
|
|
||||||
|
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
|
||||||
|
builder.HasIndex(r => r.DocNo).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(r => r.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(r => r.Customer).WithMany().HasForeignKey(r => r.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration<SalesReturnLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesReturnLine> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("sales_return_lines");
|
||||||
|
builder.HasKey(l => l.ReturnLineId);
|
||||||
|
|
||||||
|
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,8 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
||||||
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
||||||
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
|
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
|
||||||
|
public DbSet<SalesReturn> SalesReturns => Set<SalesReturn>();
|
||||||
|
public DbSet<SalesReturnLine> SalesReturnLines => Set<SalesReturnLine>();
|
||||||
|
|
||||||
// --- Reference data (docs/10 Part C.7) ---
|
// --- Reference data (docs/10 Part C.7) ---
|
||||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
|||||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||||
|
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
||||||
|
|
||||||
// Stock transactions + reference data (docs/11 §5–6)
|
// Stock transactions + reference data (docs/11 §5–6)
|
||||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Sales-return business logic — customer returns, mirroring purchase-return logic reversed.</summary>
|
||||||
|
public interface ISalesReturnService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
||||||
|
PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<SalesReturnDto> CreateAsync(CreateSalesReturnRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
|
||||||
|
Task<IReadOnlyList<SalesInvoiceLineRemainingDto>> GetRemainingByInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sales-return service. Auto-posts with a mandatory Return reason code and
|
||||||
|
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
|
||||||
|
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW
|
||||||
|
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
|
||||||
|
/// reversed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SalesReturnService : ISalesReturnService
|
||||||
|
{
|
||||||
|
private readonly IRepository<SalesReturn> _returns;
|
||||||
|
private readonly IRepository<Customer> _customers;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||||
|
private readonly IRepository<SalesInvoiceLine> _salesInvoiceLines;
|
||||||
|
private readonly IRepository<SalesReturnLine> _returnLines;
|
||||||
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
|
private readonly IStockMutator _mutator;
|
||||||
|
private readonly INumberSequenceService _numbers;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public SalesReturnService(
|
||||||
|
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||||
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
|
||||||
|
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||||
|
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_returns = returns;
|
||||||
|
_customers = customers;
|
||||||
|
_warehouses = warehouses;
|
||||||
|
_items = items;
|
||||||
|
_reasonCodes = reasonCodes;
|
||||||
|
_salesInvoiceLines = salesInvoiceLines;
|
||||||
|
_returnLines = returnLines;
|
||||||
|
_ledger = ledger;
|
||||||
|
_mutator = mutator;
|
||||||
|
_numbers = numbers;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
||||||
|
PageQuery query, int? customerId, 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 (customerId is not null) q = q.Where(r => r.CustomerId == customerId);
|
||||||
|
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 SalesReturnSummaryDto(
|
||||||
|
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status,
|
||||||
|
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.Lines.Sum(l => l.Qty)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return PagedResponse<SalesReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesReturnDto?> 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 — recovered by source-doc lookup.
|
||||||
|
var ledgerRefs = await _ledger.Query().AsNoTracking()
|
||||||
|
.Where(l => l.SourceDocType == DocumentTypes.SalesReturn && l.SourceDocId == returnId)
|
||||||
|
.OrderBy(l => l.LedgerId)
|
||||||
|
.Select(l => l.LedgerId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return ToDto(ret, ledgerRefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesReturnDto> CreateAsync(CreateSalesReturnRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request.ReasonCodeId is null)
|
||||||
|
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a sales return.", 400);
|
||||||
|
|
||||||
|
if (!await _customers.Query().AnyAsync(c => c.CustomerId == request.CustomerId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Customer {request.CustomerId} does not exist.", 422);
|
||||||
|
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||||
|
|
||||||
|
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
|
||||||
|
if (reason.Context != ReasonContext.Return)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
|
||||||
|
|
||||||
|
// Original sold qty is never mutated — "remaining returnable" is computed from
|
||||||
|
// return history instead, so the invoice keeps recording what was actually sold.
|
||||||
|
var pendingByInvoiceLine = new Dictionary<int, decimal>();
|
||||||
|
|
||||||
|
foreach (var line in request.Lines)
|
||||||
|
{
|
||||||
|
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||||
|
if (line.SalesInvoiceLineId is not null)
|
||||||
|
{
|
||||||
|
var invoiceLineId = line.SalesInvoiceLineId.Value;
|
||||||
|
var invoiceLine = await _salesInvoiceLines.Query().AsNoTracking().FirstOrDefaultAsync(l => l.SalesInvoiceLineId == invoiceLineId, ct)
|
||||||
|
?? throw new DomainException(ErrorCodes.Validation, $"Sales invoice line {invoiceLineId} does not exist.", 422);
|
||||||
|
if (invoiceLine.ItemId != line.ItemId)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Sales invoice line {invoiceLineId} is for a different item.", 422);
|
||||||
|
|
||||||
|
var alreadyReturned = await _returnLines.Query().AsNoTracking()
|
||||||
|
.Where(l => l.SalesInvoiceLineId == invoiceLineId)
|
||||||
|
.SumAsync(l => (decimal?)l.Qty, ct) ?? 0m;
|
||||||
|
pendingByInvoiceLine.TryGetValue(invoiceLineId, out var pending);
|
||||||
|
var remaining = invoiceLine.Qty - alreadyReturned - pending;
|
||||||
|
|
||||||
|
if (line.Qty > remaining)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422);
|
||||||
|
pendingByInvoiceLine[invoiceLineId] = pending + line.Qty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList();
|
||||||
|
|
||||||
|
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
var docNo = await _numbers.NextAsync(DocumentTypes.SalesReturn, token);
|
||||||
|
var ret = new SalesReturn
|
||||||
|
{
|
||||||
|
DocNo = docNo,
|
||||||
|
CustomerId = request.CustomerId,
|
||||||
|
WarehouseId = request.WarehouseId,
|
||||||
|
ReasonCodeId = request.ReasonCodeId.Value,
|
||||||
|
Status = ReturnStatus.Posted,
|
||||||
|
CreatedBy = _currentUser.AuditUserId,
|
||||||
|
CreatedAt = now,
|
||||||
|
Lines = request.Lines.Select(l => new SalesReturnLine
|
||||||
|
{
|
||||||
|
SalesInvoiceLineId = l.SalesInvoiceLineId,
|
||||||
|
ItemId = l.ItemId,
|
||||||
|
Qty = l.Qty
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
await _returns.AddAsync(ret, token);
|
||||||
|
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
|
||||||
|
|
||||||
|
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
|
||||||
|
return (ret, refs);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
// Map ledger ids after commit so they are populated.
|
||||||
|
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SalesInvoiceLineRemainingDto>> GetRemainingByInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lines = await _salesInvoiceLines.Query().AsNoTracking()
|
||||||
|
.Where(l => l.SalesInvoiceId == salesInvoiceId)
|
||||||
|
.Select(l => new { l.SalesInvoiceLineId, l.Qty })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var lineIds = lines.Select(l => l.SalesInvoiceLineId).ToList();
|
||||||
|
var returnedByLine = await _returnLines.Query().AsNoTracking()
|
||||||
|
.Where(l => l.SalesInvoiceLineId != null && lineIds.Contains(l.SalesInvoiceLineId.Value))
|
||||||
|
.GroupBy(l => l.SalesInvoiceLineId!.Value)
|
||||||
|
.Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) })
|
||||||
|
.ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct);
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.Select(l => new SalesInvoiceLineRemainingDto(
|
||||||
|
l.SalesInvoiceLineId,
|
||||||
|
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new(
|
||||||
|
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
|
||||||
|
r.Lines.OrderBy(l => l.ReturnLineId)
|
||||||
|
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
|
||||||
|
ledgerRefs);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { use, useEffect, useMemo, useState } from "react"
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, Undo2, X } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -605,8 +605,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
|
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
This invoice is {invoice.status.toLowerCase()} and cannot be edited.
|
<span>This invoice is {invoice.status.toLowerCase()} and cannot be edited.</span>
|
||||||
|
{invoice.status === "Posted" ? (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/sales/sales-returns/new?invoiceId=${invoice.salesInvoiceId}`}
|
||||||
|
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-black bg-white px-4 text-sm font-medium text-foreground shadow-sm hover:bg-muted sm:self-auto"
|
||||||
|
>
|
||||||
|
<Undo2 className="size-4" />
|
||||||
|
Return items
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import { salesReturnsApi } from "@/lib/api/sales-returns"
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { validateSalesReturnLine } from "@/lib/validations/sales"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
|
||||||
|
import { ItemListItem } from "@/types/master-data"
|
||||||
|
import { ReasonCode } from "@/types/stock"
|
||||||
|
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
interface LineState {
|
||||||
|
selected: boolean
|
||||||
|
qty: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewSalesReturnContent() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const presetInvoiceId = Number(searchParams.get("invoiceId")) || null
|
||||||
|
const presetLineId = Number(searchParams.get("lineId")) || null
|
||||||
|
|
||||||
|
const [invoices, setInvoices] = useState<SalesInvoice[] | null>(null)
|
||||||
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
|
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
|
||||||
|
const [remainingLoading, setRemainingLoading] = useState(false)
|
||||||
|
|
||||||
|
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
|
||||||
|
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||||
|
const [lineState, setLineState] = useState<Record<number, LineState>>({})
|
||||||
|
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
|
||||||
|
|
||||||
|
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([salesApi.listInvoices({ status: "Posted", pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
|
||||||
|
.then(([invoiceList, it, rc]) => {
|
||||||
|
// Only Posted invoices have stock movements to return against.
|
||||||
|
Promise.all(invoiceList.items.map((i) => salesApi.getInvoice(i.salesInvoiceId))).then((results) => setInvoices(results.map((r) => r.data)))
|
||||||
|
setItems(it.items)
|
||||||
|
setReasonCodes(rc.items)
|
||||||
|
})
|
||||||
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedInvoice = invoices?.find((i) => i.salesInvoiceId === invoiceId) ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedInvoice) {
|
||||||
|
setLineState({})
|
||||||
|
setRemainingByLine({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next: Record<number, LineState> = {}
|
||||||
|
for (const line of selectedInvoice.lines) {
|
||||||
|
next[line.salesInvoiceLineId] = { selected: false, qty: "" }
|
||||||
|
}
|
||||||
|
setLineState(next)
|
||||||
|
setRemainingLoading(true)
|
||||||
|
salesReturnsApi
|
||||||
|
.getRemaining(selectedInvoice.salesInvoiceId)
|
||||||
|
.then((rows) => {
|
||||||
|
const map: Record<number, number> = {}
|
||||||
|
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
|
||||||
|
setRemainingByLine(map)
|
||||||
|
if (presetLineId) {
|
||||||
|
const remaining = map[presetLineId] ?? 0
|
||||||
|
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
.finally(() => setRemainingLoading(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedInvoice?.salesInvoiceId])
|
||||||
|
|
||||||
|
function itemFor(itemId: number) {
|
||||||
|
return items.find((i) => i.itemId === itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingFor(lineId: number, fallback: number) {
|
||||||
|
return remainingByLine[lineId] ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLine(line: SalesInvoiceLine) {
|
||||||
|
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
|
||||||
|
setLineState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[line.salesInvoiceLineId]: { selected: !prev[line.salesInvoiceLineId]?.selected, qty: prev[line.salesInvoiceLineId]?.qty || String(remaining) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQty(lineId: number, qty: string) {
|
||||||
|
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setHeaderError(null)
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
if (!selectedInvoice) {
|
||||||
|
setHeaderError("Select an invoice to return against.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!reasonCodeId) {
|
||||||
|
setHeaderError("Select a reason code.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedLines = selectedInvoice.lines.filter((l) => lineState[l.salesInvoiceLineId]?.selected)
|
||||||
|
if (selectedLines.length === 0) {
|
||||||
|
setSubmitError("Select at least one line to return.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextErrors: Record<number, Record<string, string>> = {}
|
||||||
|
for (const line of selectedLines) {
|
||||||
|
const errors = validateSalesReturnLine({
|
||||||
|
salesInvoiceLineId: line.salesInvoiceLineId,
|
||||||
|
qty: lineState[line.salesInvoiceLineId].qty,
|
||||||
|
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
|
||||||
|
})
|
||||||
|
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
|
||||||
|
}
|
||||||
|
setLineErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
setSubmitError("Fix the highlighted lines before submitting.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadLines: CreateSalesReturnLineInput[] = selectedLines.map((l) => ({
|
||||||
|
salesInvoiceLineId: l.salesInvoiceLineId,
|
||||||
|
itemId: l.itemId,
|
||||||
|
qty: Number(lineState[l.salesInvoiceLineId].qty),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const salesReturn = await salesReturnsApi.create({
|
||||||
|
customerId: selectedInvoice.customerId,
|
||||||
|
warehouseId: selectedInvoice.warehouseId,
|
||||||
|
reasonCodeId,
|
||||||
|
lines: payloadLines,
|
||||||
|
})
|
||||||
|
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
|
||||||
|
router.push("/dashboard/sales/sales-returns")
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(errorMessage(err))
|
||||||
|
toast.error("Could not post sales return", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = !invoices
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">New Sales Return</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Return sold goods from a customer; posts an inbound ledger entry immediately.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !loadError && <Skeleton className="h-24 w-full" />}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||||
|
<Label className="text-base">Sales Invoice</Label>
|
||||||
|
<Select<number | null> value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select a posted invoice" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(invoices ?? []).map((inv) => (
|
||||||
|
<SelectItem key={inv.salesInvoiceId} value={inv.salesInvoiceId} className="text-base">
|
||||||
|
{inv.invoiceNo} — {inv.customerSnapshotName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Reason</Label>
|
||||||
|
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
|
||||||
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
|
<SelectValue placeholder="Select reason" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{reasonCodes.map((rc) => (
|
||||||
|
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
|
||||||
|
{rc.description}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headerError && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedInvoice && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Lines invoiced on {selectedInvoice.invoiceNo}</h2>
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Invoiced qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Remaining qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Unit price</TableHead>
|
||||||
|
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{selectedInvoice.lines.map((line) => {
|
||||||
|
const item = itemFor(line.itemId)
|
||||||
|
const state = lineState[line.salesInvoiceLineId] ?? { selected: false, qty: "" }
|
||||||
|
const errors = lineErrors[line.salesInvoiceLineId] ?? {}
|
||||||
|
const remaining = remainingFor(line.salesInvoiceLineId, line.qty)
|
||||||
|
const fullyReturned = !remainingLoading && remaining <= 0
|
||||||
|
return (
|
||||||
|
<TableRow key={line.salesInvoiceLineId}>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
{remainingLoading ? "…" : fullyReturned ? <span className="text-muted-foreground">Fully returned</span> : remaining}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{line.unitPrice.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={state.qty}
|
||||||
|
disabled={!state.selected || fullyReturned}
|
||||||
|
aria-invalid={!!errors.qty}
|
||||||
|
onChange={(e) => setQty(line.salesInvoiceLineId, e.target.value)}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Link href="/dashboard/sales/sales-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? "Posting…" : "Post Return"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewSalesReturnPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||||
|
<NewSalesReturnContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Undo2, Plus } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesReturnsApi } from "@/lib/api/sales-returns"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { SalesReturnSummary } from "@/types/sales"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { Warehouse } from "@/types/master-data"
|
||||||
|
import { ReasonCode } from "@/types/stock"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
|
export default function SalesReturnsListPage() {
|
||||||
|
const [returns, setReturns] = useState<SalesReturnSummary[] | null>(null)
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||||
|
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([salesReturnsApi.list(), customersApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
|
||||||
|
.then(([r, c, w, rc]) => {
|
||||||
|
setReturns(r.items)
|
||||||
|
setCustomers(c.items)
|
||||||
|
setWarehouses(w.items)
|
||||||
|
setReasonCodes(rc.items)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function customerLabel(id: number) {
|
||||||
|
const customer = customers.find((c) => c.customerId === id)
|
||||||
|
return customer ? (customer.displayName ?? customer.name) : `#${id}`
|
||||||
|
}
|
||||||
|
function warehouseCode(id: number) {
|
||||||
|
return warehouses.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||||
|
}
|
||||||
|
function reasonLabel(id: number) {
|
||||||
|
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Sales Returns</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Return sold goods from a customer, referencing the original invoice line.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New Return
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && returns === null && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && returns !== null && returns.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<Undo2 className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No sales returns yet.</p>
|
||||||
|
<Link href="/dashboard/sales/sales-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New Return
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && returns !== null && returns.length > 0 && (
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-right text-sm">Return Qty</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{returns.map((r) => (
|
||||||
|
<TableRow key={r.returnId}>
|
||||||
|
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{customerLabel(r.customerId)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5 text-right font-medium">{r.totalQty != null ? r.totalQty.toFixed(0) : "—"}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
|
||||||
|
{r.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
|
|||||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
|
import { stockApi } from "@/lib/api/stock"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ReasonCode, StockAdjustment } from "@/types/stock"
|
import { OnHand, ReasonCode, StockAdjustment } from "@/types/stock"
|
||||||
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -34,6 +35,10 @@ export default function NewWastagePage() {
|
|||||||
const [qty, setQty] = useState("")
|
const [qty, setQty] = useState("")
|
||||||
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const [onHand, setOnHand] = useState<OnHand | null>(null)
|
||||||
|
const [onHandLoading, setOnHandLoading] = useState(false)
|
||||||
|
const [onHandError, setOnHandError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -57,12 +62,31 @@ export default function NewWastagePage() {
|
|||||||
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [warehouseId])
|
}, [warehouseId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!itemId || !warehouseId) {
|
||||||
|
setOnHand(null)
|
||||||
|
setOnHandError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setOnHandLoading(true)
|
||||||
|
setOnHandError(null)
|
||||||
|
stockApi
|
||||||
|
.onHand(itemId, warehouseId)
|
||||||
|
.then(setOnHand)
|
||||||
|
.catch((err) => {
|
||||||
|
setOnHand(null)
|
||||||
|
setOnHandError(errorMessage(err))
|
||||||
|
})
|
||||||
|
.finally(() => setOnHandLoading(false))
|
||||||
|
}, [itemId, warehouseId])
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
const nextErrors: Record<string, string> = {}
|
const nextErrors: Record<string, string> = {}
|
||||||
if (!warehouseId) nextErrors.warehouseId = "Select a warehouse"
|
if (!warehouseId) nextErrors.warehouseId = "Select a warehouse"
|
||||||
if (!itemId) nextErrors.itemId = "Select an item"
|
if (!itemId) nextErrors.itemId = "Select an item"
|
||||||
if (!qty || Number(qty) <= 0) nextErrors.qty = "Quantity must be greater than 0"
|
if (!qty || Number(qty) <= 0) nextErrors.qty = "Quantity must be greater than 0"
|
||||||
|
else if (onHand && Number(qty) > onHand.available) nextErrors.qty = `Insufficient quantity — only ${onHand.available} available`
|
||||||
if (!reasonCodeId) nextErrors.reasonCodeId = "Select a wastage reason"
|
if (!reasonCodeId) nextErrors.reasonCodeId = "Select a wastage reason"
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
@@ -92,6 +116,8 @@ export default function NewWastagePage() {
|
|||||||
setQty("")
|
setQty("")
|
||||||
setReasonCodeId(null)
|
setReasonCodeId(null)
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
|
setOnHand(null)
|
||||||
|
setOnHandError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = !warehouses || !items || !reasonCodes
|
const loading = !warehouses || !items || !reasonCodes
|
||||||
@@ -192,7 +218,20 @@ export default function NewWastagePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-base">Quantity wasted</Label>
|
<Label className="text-base">Quantity wasted</Label>
|
||||||
|
{itemId && warehouseId && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{onHandLoading
|
||||||
|
? "Checking available stock…"
|
||||||
|
: onHandError
|
||||||
|
? "Available stock unknown"
|
||||||
|
: onHand
|
||||||
|
? `Available: ${onHand.available}`
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Tag,
|
Tag,
|
||||||
Truck,
|
Truck,
|
||||||
|
Undo2,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
@@ -120,6 +121,7 @@ const navItems: {
|
|||||||
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
|
||||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||||
{
|
{
|
||||||
title: "Stock",
|
title: "Stock",
|
||||||
@@ -434,7 +436,7 @@ export function AppSidebar() {
|
|||||||
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||||
// anything server-side.
|
// anything server-side.
|
||||||
const bypassCodes = new Set(["procurement", "sales", "hrm", "production", "stock"])
|
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
|
||||||
const visibleItems = loading
|
const visibleItems = loading
|
||||||
? []
|
? []
|
||||||
: navItems
|
: navItems
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// One typed client method per Sales Return endpoint. Auto-posts an inbound
|
||||||
|
// FIFO movement on create, mirroring lib/api/purchase-returns.ts with the
|
||||||
|
// direction reversed.
|
||||||
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
|
import { PagedResponse } from "@/types/common"
|
||||||
|
import { CreateSalesReturnRequest, SalesInvoiceLineRemaining, SalesReturn, SalesReturnSummary } from "@/types/sales"
|
||||||
|
|
||||||
|
export interface ListSalesReturnsParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
|
customerId?: number
|
||||||
|
warehouseId?: number
|
||||||
|
sort?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesReturnsApi = {
|
||||||
|
list(params: ListSalesReturnsParams = {}): Promise<PagedResponse<SalesReturnSummary>> {
|
||||||
|
return apiRequest<PagedResponse<SalesReturnSummary>>(`/sales-returns${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
get(returnId: number): Promise<SalesReturn> {
|
||||||
|
return apiRequest<SalesReturn>(`/sales-returns/${returnId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned). */
|
||||||
|
getRemaining(salesInvoiceId: number): Promise<SalesInvoiceLineRemaining[]> {
|
||||||
|
return apiRequest<SalesInvoiceLineRemaining[]>(`/sales-returns/remaining${buildQuery({ salesInvoiceId })}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 400 REASON_CODE_REQUIRED without a reason; 422 if it is not a Return-context reason;
|
||||||
|
* 409 STOCK_NEGATIVE_BLOCKED-equivalent errors do not apply here (inbound movement).
|
||||||
|
*/
|
||||||
|
create(request: CreateSalesReturnRequest): Promise<SalesReturn> {
|
||||||
|
return apiRequest<SalesReturn>("/sales-returns", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Client-side UX validation only — required fields, format/range checks the
|
||||||
|
// browser can already see. Server-authoritative rules (referential existence,
|
||||||
|
// concurrency, reason-code context) are never re-implemented here. Same
|
||||||
|
// pattern as lib/validations/procurement.ts's validateReturnLine.
|
||||||
|
|
||||||
|
export function validateSalesReturnLine(input: { salesInvoiceLineId: number | null; qty: string; maxQty: number | null }): Record<string, string> {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
if (!input.salesInvoiceLineId) errors.salesInvoiceLineId = "Select an invoiced line"
|
||||||
|
const qty = Number(input.qty)
|
||||||
|
if (!input.qty || Number.isNaN(qty) || qty <= 0) errors.qty = "Quantity must be greater than 0"
|
||||||
|
// Client-side sanity bound on the remaining returnable qty — the server remains authoritative.
|
||||||
|
if (input.maxQty !== null && qty > input.maxQty) {
|
||||||
|
errors.qty = input.maxQty <= 0
|
||||||
|
? "This line has already been fully returned"
|
||||||
|
: `Insufficient quantity — only ${input.maxQty} remain returnable`
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
@@ -307,3 +307,61 @@ export interface SalesCustomer {
|
|||||||
taxNo: string | null
|
taxNo: string | null
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sales Returns -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type SalesReturnStatus = "Draft" | "Posted"
|
||||||
|
|
||||||
|
export interface SalesReturnLine {
|
||||||
|
returnLineId: number
|
||||||
|
/** Optional: a return may reference the originating sales invoice line for traceability. */
|
||||||
|
salesInvoiceLineId: number | null
|
||||||
|
itemId: number
|
||||||
|
qty: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesReturn {
|
||||||
|
returnId: number
|
||||||
|
docNo: string
|
||||||
|
customerId: number
|
||||||
|
warehouseId: number
|
||||||
|
reasonCodeId: number
|
||||||
|
status: SalesReturnStatus
|
||||||
|
createdBy: number
|
||||||
|
createdAt: string
|
||||||
|
lines: SalesReturnLine[]
|
||||||
|
ledgerRefs: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesReturnSummary {
|
||||||
|
returnId: number
|
||||||
|
docNo: string
|
||||||
|
customerId: number
|
||||||
|
warehouseId: number
|
||||||
|
reasonCodeId: number
|
||||||
|
status: SalesReturnStatus
|
||||||
|
createdBy: number
|
||||||
|
createdAt: string
|
||||||
|
lineCount: number
|
||||||
|
totalQty: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remaining returnable qty for one sales invoice line (invoiced qty minus already-returned). */
|
||||||
|
export interface SalesInvoiceLineRemaining {
|
||||||
|
salesInvoiceLineId: number
|
||||||
|
remainingQty: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesReturnLineInput {
|
||||||
|
salesInvoiceLineId?: number | null
|
||||||
|
itemId: number
|
||||||
|
qty: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesReturnRequest {
|
||||||
|
customerId: number
|
||||||
|
warehouseId: number
|
||||||
|
/** Mandatory; omitting it returns 400 REASON_CODE_REQUIRED. */
|
||||||
|
reasonCodeId: number
|
||||||
|
lines: CreateSalesReturnLineInput[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user