diff --git a/Backend/ERPCore/Controllers/SalesReturnsController.cs b/Backend/ERPCore/Controllers/SalesReturnsController.cs new file mode 100644 index 0000000..a550da6 --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesReturnsController.cs @@ -0,0 +1,49 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Sales-return endpoints — customer returns of previously sold goods. +[Route("api/v1/sales-returns")] +public sealed class SalesReturnsController : ApiControllerBase +{ + private readonly ISalesReturnService _returns; + + public SalesReturnsController(ISalesReturnService returns) => _returns = returns; + + /// List posted returns, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _returns.ListAsync(query, customerId, warehouseId, ct)); + + /// Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned). + [HttpGet("remaining")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct) + => Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct)); + + /// Get one return with its lines and the ledger entries it posted. + [HttpGet("{returnId:int}")] + [ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int returnId, CancellationToken ct) + { + var dto = await _returns.GetAsync(returnId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + /// Create + auto-post a return (inbound movement). + [HttpPost] + [ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct) + { + var dto = await _returns.CreateAsync(request, ct); + return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto); + } +} diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs index cbd91da..69e1419 100644 --- a/Backend/ERPCore/Domain/DocumentTypes.cs +++ b/Backend/ERPCore/Domain/DocumentTypes.cs @@ -20,4 +20,5 @@ public static class DocumentTypes public const string SalesInvoice = "SI"; public const string SalesSlip = "SSL"; public const string BundleSale = "BND"; + public const string SalesReturn = "SRET"; } diff --git a/Backend/ERPCore/Domain/Entities/SalesReturn.cs b/Backend/ERPCore/Domain/Entities/SalesReturn.cs new file mode 100644 index 0000000..749870c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesReturn.cs @@ -0,0 +1,32 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Sales return header — a customer returns previously sold goods, generating an +/// inbound stock movement. Auto-posts with a mandatory reason code, mirroring +/// with the direction reversed. +/// +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 Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs new file mode 100644 index 0000000..191adc0 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs @@ -0,0 +1,21 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Sales-return line referencing the original sales invoice line for traceability. +/// is in base UOM. +/// +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; } +} diff --git a/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs new file mode 100644 index 0000000..4aab60b --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs @@ -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 Lines, IReadOnlyList LedgerRefs); + +/// Row shape for GET /sales-returns — no lines/ledgerRefs (those need a per-row query). +public sealed record SalesReturnSummaryDto( + int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty); + +/// +/// Remaining returnable qty for one sales invoice line — the invoiced qty minus +/// whatever has already been returned against it. The invoice line's own Qty +/// is never mutated by a return, so this is computed on read from return history. +/// +public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty); + +// Requests -------------------------------------------------------------------- + +public sealed class CreateSalesReturnLineInput +{ + /// Original sales invoice line, for traceability against the sale. + 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; } + /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error. + public int? ReasonCodeId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs new file mode 100644 index 0000000..d835cbb --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index 5335209..47e402c 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -93,6 +93,8 @@ public class ErpDbContext : DbContext public DbSet BundleSaleTemplateLines => Set(); public DbSet BundleSales => Set(); public DbSet BundleSaleLines => Set(); + public DbSet SalesReturns => Set(); + public DbSet SalesReturnLines => Set(); // --- Reference data (docs/10 Part C.7) --- public DbSet ReasonCodes => Set(); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index ed900c3..83099de 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -115,6 +115,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Stock transactions + reference data (docs/11 §5–6) builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesReturnService.cs b/Backend/ERPCore/Services/Interfaces/ISalesReturnService.cs new file mode 100644 index 0000000..36cfbcf --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesReturnService.cs @@ -0,0 +1,18 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +/// Sales-return business logic — customer returns, mirroring purchase-return logic reversed. +public interface ISalesReturnService +{ + Task> ListAsync( + PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default); + + Task GetAsync(int returnId, CancellationToken ct = default); + + Task CreateAsync(CreateSalesReturnRequest request, CancellationToken ct = default); + + /// Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned). + Task> GetRemainingByInvoiceAsync(int salesInvoiceId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/SalesReturnService.cs b/Backend/ERPCore/Services/SalesReturnService.cs new file mode 100644 index 0000000..efb6a4a --- /dev/null +++ b/Backend/ERPCore/Services/SalesReturnService.cs @@ -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; + +/// +/// Sales-return service. Auto-posts with a mandatory Return reason code and +/// generates an inbound stock movement via the shared +/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW +/// transaction, mirroring with the direction +/// reversed. +/// +public sealed class SalesReturnService : ISalesReturnService +{ + private readonly IRepository _returns; + private readonly IRepository _customers; + private readonly IRepository _warehouses; + private readonly IRepository _items; + private readonly IRepository _reasonCodes; + private readonly IRepository _salesInvoiceLines; + private readonly IRepository _returnLines; + private readonly IRepository _ledger; + private readonly IStockMutator _mutator; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public SalesReturnService( + IRepository returns, IRepository customers, IRepository warehouses, + IRepository items, IRepository reasonCodes, IRepository salesInvoiceLines, + IRepository returnLines, IRepository 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> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task 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 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(); + + 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> 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 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); +} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index 6e7a269..f803b62 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -3,7 +3,7 @@ import { use, useEffect, useMemo, useState } from "react" import Link from "next/link" 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 { customersApi } from "@/lib/api/customers" @@ -605,8 +605,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i ) : ( -
- This invoice is {invoice.status.toLowerCase()} and cannot be edited. +
+ This invoice is {invoice.status.toLowerCase()} and cannot be edited. + {invoice.status === "Posted" ? ( + + + Return items + + ) : null}
)}
diff --git a/Frontend/erp-system/app/dashboard/sales/sales-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/sales-returns/new/page.tsx new file mode 100644 index 0000000..dbb575e --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/sales-returns/new/page.tsx @@ -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(null) + const [items, setItems] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [loadError, setLoadError] = useState(null) + const [remainingByLine, setRemainingByLine] = useState>({}) + const [remainingLoading, setRemainingLoading] = useState(false) + + const [invoiceId, setInvoiceId] = useState(presetInvoiceId) + const [reasonCodeId, setReasonCodeId] = useState(null) + const [lineState, setLineState] = useState>({}) + const [lineErrors, setLineErrors] = useState>>({}) + + const [headerError, setHeaderError] = useState(null) + const [submitError, setSubmitError] = useState(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 = {} + 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 = {} + 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> = {} + 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 ( +
+
+
+

New Sales Return

+

Return sold goods from a customer; posts an inbound ledger entry immediately.

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + value={invoiceId} onValueChange={setInvoiceId} disabled={!!presetInvoiceId}> + + + + + {(invoices ?? []).map((inv) => ( + + {inv.invoiceNo} — {inv.customerSnapshotName} + + ))} + + +
+
+ + value={reasonCodeId} onValueChange={setReasonCodeId}> + + + + + {reasonCodes.map((rc) => ( + + {rc.description} + + ))} + + +
+
+ + {headerError && ( +
{headerError}
+ )} + + {selectedInvoice && ( +
+

Lines invoiced on {selectedInvoice.invoiceNo}

+ + + + + Item + Invoiced qty + Remaining qty + Unit price + Return qty + + + + {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 ( + + + toggleLine(line)} /> + + {item ? `${item.sku} — ${item.name}` : line.description} + {line.qty} + + {remainingLoading ? "…" : fullyReturned ? Fully returned : remaining} + + {line.unitPrice.toFixed(2)} + + setQty(line.salesInvoiceLineId, e.target.value)} + className="h-11 text-base" + /> + + + + ) + })} + +
+
+ )} + + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewSalesReturnPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/sales/sales-returns/page.tsx b/Frontend/erp-system/app/dashboard/sales/sales-returns/page.tsx new file mode 100644 index 0000000..82063b1 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/sales/sales-returns/page.tsx @@ -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(null) + const [customers, setCustomers] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [error, setError] = useState(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 ( +
+
+
+

Sales Returns

+

Return sold goods from a customer, referencing the original invoice line.

+
+ + + New Return + +
+ + {error && ( +
{error}
+ )} + + {!error && returns === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && returns !== null && returns.length === 0 && ( +
+ +

No sales returns yet.

+ + + New Return + +
+ )} + + {!error && returns !== null && returns.length > 0 && ( + + + + Doc No + Customer + Warehouse + Reason + Return Qty + Status + Created + + + + {returns.map((r) => ( + + {r.docNo} + {customerLabel(r.customerId)} + {warehouseCode(r.warehouseId)} + {reasonLabel(r.reasonCodeId)} + {r.totalQty != null ? r.totalQty.toFixed(0) : "—"} + + + {r.status} + + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx index bb3a380..0db4863 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/new/page.tsx @@ -8,9 +8,10 @@ import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage" import { reasonCodesApi } from "@/lib/api/reason-codes" import { warehousesApi } from "@/lib/api/warehouses" import { itemsApi } from "@/lib/api/items" +import { stockApi } from "@/lib/api/stock" import { errorMessage } from "@/lib/error-map" 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 { Button, buttonVariants } from "@/components/ui/button" @@ -34,6 +35,10 @@ export default function NewWastagePage() { const [qty, setQty] = useState("") const [reasonCodeId, setReasonCodeId] = useState(null) + const [onHand, setOnHand] = useState(null) + const [onHandLoading, setOnHandLoading] = useState(false) + const [onHandError, setOnHandError] = useState(null) + const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) @@ -57,12 +62,31 @@ export default function NewWastagePage() { warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([])) }, [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() { setSubmitError(null) const nextErrors: Record = {} if (!warehouseId) nextErrors.warehouseId = "Select a warehouse" if (!itemId) nextErrors.itemId = "Select an item" 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" setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return @@ -92,6 +116,8 @@ export default function NewWastagePage() { setQty("") setReasonCodeId(null) setSubmitError(null) + setOnHand(null) + setOnHandError(null) } const loading = !warehouses || !items || !reasonCodes @@ -192,7 +218,20 @@ export default function NewWastagePage() {
- +
+ + {itemId && warehouseId && ( + + {onHandLoading + ? "Checking available stock…" + : onHandError + ? "Available stock unknown" + : onHand + ? `Available: ${onHand.available}` + : null} + + )} +
> { + return apiRequest>(`/sales-returns${buildQuery(params)}`) + }, + + get(returnId: number): Promise { + return apiRequest(`/sales-returns/${returnId}`) + }, + + /** Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned). */ + getRemaining(salesInvoiceId: number): Promise { + return apiRequest(`/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 { + return apiRequest("/sales-returns", { method: "POST", body: request }) + }, +} diff --git a/Frontend/erp-system/lib/validations/sales.ts b/Frontend/erp-system/lib/validations/sales.ts new file mode 100644 index 0000000..c22cc07 --- /dev/null +++ b/Frontend/erp-system/lib/validations/sales.ts @@ -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 { + const errors: Record = {} + 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 +} diff --git a/Frontend/erp-system/types/sales.ts b/Frontend/erp-system/types/sales.ts index 7551a34..69bb168 100644 --- a/Frontend/erp-system/types/sales.ts +++ b/Frontend/erp-system/types/sales.ts @@ -307,3 +307,61 @@ export interface SalesCustomer { taxNo: string | null 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[] +}