Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae6a87022d | |||
| ee9fa40edf | |||
| b1b0fe4bac | |||
| 8555702a76 | |||
| fd95e92eb1 | |||
| 6e008773db | |||
| 179d5b0803 | |||
| 342012a321 | |||
| ee6ac913f1 | |||
| 2661169351 | |||
| 80213cf47d | |||
| 18475fdc9f | |||
| 9a32c5c609 | |||
| 32f40e9d1a | |||
| d16a227b54 | |||
| 7219480ca0 |
+1
-4
@@ -36,7 +36,4 @@ Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
# ── Migrations ─────────────────────────────────────────────────────────
|
||||
# Each dev keeps EF Core migrations local; DB schema changes are announced
|
||||
# to the team instead of committed, so migration files aren't shared here.
|
||||
Migrations/
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ namespace ERPCore.Controllers;
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
private readonly IGrnPaymentService _payments;
|
||||
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
public GrnsController(IGrnService grns, IGrnPaymentService payments)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
}
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
[HttpGet]
|
||||
@@ -59,4 +64,23 @@ public sealed class GrnsController : ApiControllerBase
|
||||
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
|
||||
|
||||
/// <summary>Pay the vendor against this GRN's balance, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(GrnPaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<GrnPaymentDto>> Pay(int grnId, [FromBody] CreateGrnPaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(grnId, request, ct);
|
||||
return Created($"/api/v1/grns/{grnId}/payments/{dto.GrnPaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this GRN, newest first.</summary>
|
||||
[HttpGet("{grnId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<GrnPaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<GrnPaymentDto>>> ListPayments(int grnId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(grnId, ct));
|
||||
}
|
||||
|
||||
@@ -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 SalesSlip = "SSL";
|
||||
public const string BundleSale = "BND";
|
||||
public const string SalesReturn = "SRET";
|
||||
}
|
||||
|
||||
@@ -30,8 +30,18 @@ public class Grn
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
/// <summary>Sum of vendor payments made against this GRN's total payable (<see cref="GrnLine.LineTotal"/>).</summary>
|
||||
public decimal PaidAmount { get; set; }
|
||||
/// <summary>Total payable minus <see cref="PaidAmount"/>; installments accrue against this until it reaches zero.</summary>
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
||||
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
|
||||
}
|
||||
|
||||
@@ -58,4 +58,11 @@ public class GrnLine
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
|
||||
/// <summary>
|
||||
/// One row per received unit when <see cref="Item.Warranty"/> is
|
||||
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
|
||||
/// Empty for a non-warranty item.
|
||||
/// </summary>
|
||||
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One warranty number captured against a single received unit of a warranty-tracked
|
||||
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
|
||||
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
|
||||
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
|
||||
/// </summary>
|
||||
public class GrnLineWarrantyNumber
|
||||
{
|
||||
public int GrnLineWarrantyNumberId { get; set; }
|
||||
|
||||
public int GrnLineId { get; set; }
|
||||
public GrnLine? GrnLine { get; set; }
|
||||
|
||||
public string WarrantyNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
|
||||
public int WarrantyPeriodMonths { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A vendor payment made against a confirmed GRN's balance (installments allowed —
|
||||
/// see GrnPaymentService.PayAsync). Posts its own real GL journal entry (Debit GRN
|
||||
/// Clearing / Credit the selected bank-or-cash account) before being recorded here.
|
||||
/// </summary>
|
||||
public class GrnPayment
|
||||
{
|
||||
public int GrnPaymentId { get; set; }
|
||||
|
||||
public int GrnId { get; set; }
|
||||
public Grn? Grn { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime PaymentDate { get; set; }
|
||||
|
||||
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
|
||||
public long GlBankAccountId { get; set; }
|
||||
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
|
||||
public string BankAccountName { get; set; } = string.Empty;
|
||||
|
||||
public string? Reference { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -38,6 +38,9 @@ public class Item
|
||||
|
||||
public StockNature StockNature { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
public string? TaxClass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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,17 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same
|
||||
/// convention as <see cref="StockNature"/> and <see cref="TrackingMode"/>.
|
||||
/// </summary>
|
||||
public enum Warranty
|
||||
{
|
||||
NonWarranty,
|
||||
Warranty
|
||||
}
|
||||
|
||||
/// <summary>Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01).</summary>
|
||||
public static class WarrantyPeriods
|
||||
{
|
||||
public static readonly int[] AllowedMonths = { 3, 6, 12, 18 };
|
||||
}
|
||||
@@ -5,32 +5,47 @@ namespace ERPCore.Dtos.Grn;
|
||||
|
||||
// Responses (docs/11 §4) --------------------------------------------------------
|
||||
|
||||
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
|
||||
|
||||
public sealed record GrnLineDto(
|
||||
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
|
||||
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
|
||||
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
|
||||
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
|
||||
HoldStatus HoldStatus, int? BatchId);
|
||||
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
|
||||
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
|
||||
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record GrnSummaryDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||
decimal PaidAmount, decimal BalanceAmount);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
public sealed record GrnConfirmResultDto(
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt,
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo, decimal BalanceAmount,
|
||||
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||
|
||||
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
|
||||
|
||||
public sealed record GrnPaymentDto(
|
||||
int GrnPaymentId, int GrnId, decimal Amount, DateTime PaymentDate,
|
||||
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
|
||||
|
||||
public sealed class CreateGrnPaymentRequest
|
||||
{
|
||||
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
|
||||
[Required] public long GlBankAccountId { get; set; }
|
||||
[StringLength(100)] public string? Reference { get; set; }
|
||||
}
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class BatchInput
|
||||
@@ -58,6 +73,11 @@ public sealed class CreateGrnLineInput
|
||||
[Range(0, 100)] public decimal VatPct { get; set; }
|
||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
public BatchInput? Batch { get; set; }
|
||||
/// <summary>
|
||||
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
|
||||
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
|
||||
/// </summary>
|
||||
public List<string>? WarrantyNumbers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateGrnRequest
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ERPCore.Dtos.Items;
|
||||
public sealed record ItemListItemDto(
|
||||
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
@@ -29,6 +30,7 @@ public sealed record ItemDetailDto(
|
||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||
StockNature StockNature, TrackingMode TrackingMode,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SalePrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
@@ -59,6 +61,9 @@ public sealed class CreateItemRequest
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||
@@ -86,6 +91,9 @@ public sealed class UpdateItemRequest
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SalePrice { 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();
|
||||
}
|
||||
@@ -16,6 +16,9 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
|
||||
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(g => g.CreatedAt).IsRequired();
|
||||
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(g => g.PaidAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.BalanceAmount).HasPrecision(18, 4);
|
||||
builder.Property(g => g.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
|
||||
@@ -28,6 +31,27 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnPaymentConfiguration : IEntityTypeConfiguration<GrnPayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnPayment> builder)
|
||||
{
|
||||
builder.ToTable("grn_payments");
|
||||
builder.HasKey(p => p.GrnPaymentId);
|
||||
|
||||
builder.Property(p => p.Amount).HasPrecision(18, 4);
|
||||
builder.Property(p => p.PaymentDate).IsRequired();
|
||||
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(p => p.Reference).HasMaxLength(100);
|
||||
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(p => p.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(p => p.Grn).WithMany(g => g.Payments).HasForeignKey(p => p.GrnId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(p => p.GrnId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLine> builder)
|
||||
@@ -53,3 +77,21 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguration<GrnLineWarrantyNumber>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLineWarrantyNumber> builder)
|
||||
{
|
||||
builder.ToTable("grn_line_warranty_numbers");
|
||||
builder.HasKey(w => w.GrnLineWarrantyNumberId);
|
||||
|
||||
builder.Property(w => w.WarrantyNo).IsRequired().HasMaxLength(100);
|
||||
|
||||
builder.HasOne(w => w.GrnLine).WithMany(l => l.WarrantyNumbers)
|
||||
.HasForeignKey(w => w.GrnLineId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// A warranty number entered twice on the same line is almost certainly a typo —
|
||||
// catch it at the DB, not just client-side.
|
||||
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.TrackingMode)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.Warranty)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(Warranty.NonWarranty);
|
||||
builder.Property(i => i.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public class ErpDbContext : DbContext
|
||||
// --- Goods Receipt (docs/10 Part C.3) ---
|
||||
public DbSet<Grn> Grns => Set<Grn>();
|
||||
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
|
||||
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
|
||||
|
||||
// --- Batch / Serial (docs/10 Part C.4) ---
|
||||
public DbSet<Batch> Batches => Set<Batch>();
|
||||
@@ -93,6 +94,8 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
||||
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
||||
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) ---
|
||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6956
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemTypeIsMeasurable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsMeasurable",
|
||||
table: "item_types",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsMeasurable",
|
||||
table: "item_types");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnGlPostingAndPayments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "BalanceAmount",
|
||||
table: "grns",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GlJournalNo",
|
||||
table: "grns",
|
||||
type: "character varying(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GlPostedAt",
|
||||
table: "grns",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PaidAmount",
|
||||
table: "grns",
|
||||
type: "numeric(18,4)",
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_payments",
|
||||
columns: table => new
|
||||
{
|
||||
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
GrnId = table.Column<int>(type: "integer", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_payments_grns_GrnId",
|
||||
column: x => x.GrnId,
|
||||
principalTable: "grns",
|
||||
principalColumn: "GrnId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_payments_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_payments_CreatedBy",
|
||||
table: "grn_payments",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_payments_GrnId",
|
||||
table: "grn_payments",
|
||||
column: "GrnId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BalanceAmount",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlJournalNo",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlPostedAt",
|
||||
table: "grns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaidAmount",
|
||||
table: "grns");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class warrenty : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Warranty",
|
||||
table: "items",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "NonWarranty");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "WarrantyPeriodMonths",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_line_warranty_numbers",
|
||||
columns: table => new
|
||||
{
|
||||
GrnLineWarrantyNumberId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
GrnLineId = table.Column<int>(type: "integer", nullable: false),
|
||||
WarrantyNo = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
WarrantyPeriodMonths = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_line_warranty_numbers", x => x.GrnLineWarrantyNumberId);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_line_warranty_numbers_grn_lines_GrnLineId",
|
||||
column: x => x.GrnLineId,
|
||||
principalTable: "grn_lines",
|
||||
principalColumn: "GrnLineId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_returns",
|
||||
columns: table => new
|
||||
{
|
||||
ReturnId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
CustomerId = table.Column<int>(type: "integer", nullable: false),
|
||||
WarehouseId = table.Column<int>(type: "integer", nullable: false),
|
||||
ReasonCodeId = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_returns", x => x.ReturnId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_customers_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalTable: "customers",
|
||||
principalColumn: "CustomerId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_reason_codes_ReasonCodeId",
|
||||
column: x => x.ReasonCodeId,
|
||||
principalTable: "reason_codes",
|
||||
principalColumn: "ReasonCodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_returns_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sales_return_lines",
|
||||
columns: table => new
|
||||
{
|
||||
ReturnLineId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ReturnId = table.Column<int>(type: "integer", nullable: false),
|
||||
SalesInvoiceLineId = table.Column<int>(type: "integer", nullable: true),
|
||||
ItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sales_return_lines", x => x.ReturnLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_return_lines_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_return_lines_sales_invoice_lines_SalesInvoiceLineId",
|
||||
column: x => x.SalesInvoiceLineId,
|
||||
principalTable: "sales_invoice_lines",
|
||||
principalColumn: "SalesInvoiceLineId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_sales_return_lines_sales_returns_ReturnId",
|
||||
column: x => x.ReturnId,
|
||||
principalTable: "sales_returns",
|
||||
principalColumn: "ReturnId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_line_warranty_numbers_GrnLineId_WarrantyNo",
|
||||
table: "grn_line_warranty_numbers",
|
||||
columns: new[] { "GrnLineId", "WarrantyNo" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_return_lines_ItemId",
|
||||
table: "sales_return_lines",
|
||||
column: "ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_return_lines_ReturnId",
|
||||
table: "sales_return_lines",
|
||||
column: "ReturnId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_return_lines_SalesInvoiceLineId",
|
||||
table: "sales_return_lines",
|
||||
column: "SalesInvoiceLineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_CreatedBy",
|
||||
table: "sales_returns",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_CustomerId",
|
||||
table: "sales_returns",
|
||||
column: "CustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_DocNo",
|
||||
table: "sales_returns",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_ReasonCodeId",
|
||||
table: "sales_returns",
|
||||
column: "ReasonCodeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sales_returns_WarehouseId",
|
||||
table: "sales_returns",
|
||||
column: "WarehouseId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_line_warranty_numbers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_return_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sales_returns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Warranty",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WarrantyPeriodMonths",
|
||||
table: "items");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,7 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
|
||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
|
||||
|
||||
// Sales (Phase 1)
|
||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||
@@ -115,6 +116,7 @@ builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
||||
|
||||
// Stock transactions + reference data (docs/11 §5–6)
|
||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IGeneralLedgerClient _client;
|
||||
|
||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
||||
@@ -13,4 +23,89 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
||||
|
||||
public async Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
{
|
||||
using var body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(request, JsonOptions));
|
||||
var response = await _client.SendAsync(HttpMethod.Post, "journal-entries", null, "application/json", body, ct);
|
||||
var data = ParseSuccess<GlJournalEntryData>(response);
|
||||
return new GlJournalEntryResult(data.JournalNo, data.IsPosted);
|
||||
}
|
||||
|
||||
public async Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.SendAsync(
|
||||
HttpMethod.Get, "fiscal-years/periods/by-date", $"?date={date:yyyy-MM-dd}", null, null, ct);
|
||||
var data = ParseSuccess<GlPeriodData>(response);
|
||||
return new GlPeriod(data.PeriodId, data.FiscalYearId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
{
|
||||
var response = await _client.SendAsync(HttpMethod.Get, "bank-accounts", "?accountType=Both", null, null, ct);
|
||||
var data = ParseSuccess<List<GlBankAccountData>>(response);
|
||||
return data.Select(a => new GlBankAccount(
|
||||
a.AccountType, a.AccountId, a.AccountName, a.BankName,
|
||||
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses GL's <c>ApiResponse</c> envelope (case-insensitively, since GL's success bodies are
|
||||
/// camelCase and its error bodies are PascalCase — docs/12 §4) and throws
|
||||
/// <see cref="DomainException"/> if the call didn't succeed.
|
||||
/// </summary>
|
||||
private static T ParseSuccess<T>(GeneralLedgerResponse response)
|
||||
{
|
||||
GlEnvelope<T>? envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<GlEnvelope<T>>(response.Body, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
envelope = null;
|
||||
}
|
||||
|
||||
if (response.StatusCode is < 200 or >= 300 || envelope is null || !envelope.Success || envelope.Data is null)
|
||||
{
|
||||
var message = envelope?.Message ?? "The General Ledger service rejected the request.";
|
||||
var status = response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502;
|
||||
throw new DomainException(ErrorCodes.GlRequestFailed, message, status);
|
||||
}
|
||||
|
||||
return envelope.Data;
|
||||
}
|
||||
|
||||
private sealed class GlEnvelope<T>
|
||||
{
|
||||
public int StatusCode { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlJournalEntryData
|
||||
{
|
||||
public string JournalNo { get; set; } = string.Empty;
|
||||
public bool IsPosted { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlPeriodData
|
||||
{
|
||||
public int PeriodId { get; set; }
|
||||
public int FiscalYearId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlBankAccountData
|
||||
{
|
||||
public string AccountType { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public string? BankName { get; set; }
|
||||
public string? CashAccountTypeName { get; set; }
|
||||
public string? AccountNumber { get; set; }
|
||||
public long GlAccountId { get; set; }
|
||||
public string GlAccountCode { get; set; } = string.Empty;
|
||||
public string CurrencyCode { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ERPCore.Services.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Purpose-built request/response shapes for the specific GL endpoints ERPCore's GRN
|
||||
/// module calls directly (journal-entries, period lookup, bank-account listing) — not
|
||||
/// a full model of GL's contract (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6: typed
|
||||
/// DTOs are added only for the flow that actually needs them).
|
||||
/// </summary>
|
||||
public sealed record GlJournalEntryLineRequest(string AccountCode, decimal DebitAmount, decimal CreditAmount, string? Memo = null);
|
||||
|
||||
public sealed class GlJournalEntryRequest
|
||||
{
|
||||
public int PeriodId { get; set; }
|
||||
public DateOnly EntryDate { get; set; }
|
||||
public string? SourceModule { get; set; }
|
||||
public string? Reference { get; set; }
|
||||
public string? Narration { get; set; }
|
||||
public List<GlJournalEntryLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed record GlJournalEntryResult(string JournalNo, bool IsPosted);
|
||||
|
||||
public sealed record GlPeriod(int PeriodId, int FiscalYearId);
|
||||
|
||||
public sealed record GlBankAccount(
|
||||
string AccountType, long AccountId, string AccountName, string? BankName,
|
||||
string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode);
|
||||
@@ -0,0 +1,120 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
|
||||
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
|
||||
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
|
||||
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
|
||||
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
|
||||
/// </summary>
|
||||
public sealed class GrnPaymentService : IGrnPaymentService
|
||||
{
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly IRepository<GrnPayment> _payments;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public GrnPaymentService(
|
||||
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
if (grn.Status == GrnStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
|
||||
if (request.Amount > grn.BalanceAmount)
|
||||
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
|
||||
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "GRN_PAYMENT",
|
||||
Reference = grn.DocNo,
|
||||
Narration = $"Payment against GRN {grn.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
|
||||
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
var entity = new GrnPayment
|
||||
{
|
||||
GrnId = grn.GrnId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
|
||||
grn.PaidAmount += request.Amount;
|
||||
grn.BalanceAmount -= request.Amount;
|
||||
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
|
||||
throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
return await _payments.Query().AsNoTracking()
|
||||
.Where(p => p.GrnId == grnId)
|
||||
.OrderByDescending(p => p.GrnPaymentId)
|
||||
.Select(p => new GrnPaymentDto(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static GrnPaymentDto Map(GrnPayment p) => new(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -38,13 +39,18 @@ public sealed class GrnService : IGrnService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glVatRecoverableAccountCode;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
@@ -60,6 +66,10 @@ public sealed class GrnService : IGrnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
@@ -82,7 +92,7 @@ public sealed class GrnService : IGrnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new GrnSummaryDto(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -91,7 +101,7 @@ public sealed class GrnService : IGrnService
|
||||
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().AsNoTracking()
|
||||
.Include(g => g.Lines)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
return grn is null ? null : Map(grn);
|
||||
}
|
||||
@@ -165,6 +175,7 @@ public sealed class GrnService : IGrnService
|
||||
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
{
|
||||
@@ -181,7 +192,8 @@ public sealed class GrnService : IGrnService
|
||||
VatAmount = vatAmount,
|
||||
ReceivedValue = receivedValue,
|
||||
LineTotal = receivedValue + vatAmount,
|
||||
HoldStatus = input.HoldStatus
|
||||
HoldStatus = input.HoldStatus,
|
||||
WarrantyNumbers = warrantyNumbers
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,15 +270,47 @@ public sealed class GrnService : IGrnService
|
||||
}
|
||||
}
|
||||
|
||||
// Post the real GL journal entry for this receipt before committing — if GL
|
||||
// rejects it or is unreachable, the exception propagates out of this callback
|
||||
// and the whole transaction (FIFO layers, stock ledger, PO accrual) rolls back
|
||||
// with it, so inventory and the ledger never diverge (user-approved "fails
|
||||
// atomically" behavior; residual risk if GL posts but the local commit still
|
||||
// fails afterward is accepted for this phase, see the integration plan).
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var glLines = new List<GlJournalEntryLineRequest>();
|
||||
decimal totalPayable = 0m;
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
|
||||
if (line.VatAmount > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
|
||||
totalPayable += line.LineTotal;
|
||||
}
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));
|
||||
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "GRN",
|
||||
Reference = grn.DocNo,
|
||||
Narration = $"Goods received - GRN {grn.DocNo}",
|
||||
Lines = glLines
|
||||
}, token);
|
||||
|
||||
grn.Status = GrnStatus.Confirmed;
|
||||
grn.PostedAt = now;
|
||||
grn.GlJournalNo = posted.JournalNo;
|
||||
grn.GlPostedAt = now;
|
||||
grn.PaidAmount = 0m;
|
||||
grn.BalanceAmount = totalPayable;
|
||||
|
||||
await UpdatePoStatusAsync(grn.PoId, token);
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, now,
|
||||
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
createdLayers.Select(ToCreatedLayer).ToList(),
|
||||
ledgerRefs.Select(l => l.LedgerId).ToList(),
|
||||
await GetPoStatusAsync(grn.PoId, ct));
|
||||
@@ -341,6 +385,30 @@ public sealed class GrnService : IGrnService
|
||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item requires exactly one warranty number per received unit —
|
||||
/// same shape of rule as <see cref="ResolveBatchAsync"/> for batch tracking. The coverage
|
||||
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
|
||||
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
|
||||
/// </summary>
|
||||
private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty)
|
||||
{
|
||||
if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
|
||||
if (item.WarrantyPeriodMonths is null)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);
|
||||
|
||||
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
|
||||
if (trimmed.Count != qty)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Item {item.Sku} is under warranty; provide exactly {qty} warranty number(s), got {trimmed.Count}.", 422);
|
||||
if (trimmed.Distinct(StringComparer.OrdinalIgnoreCase).Count() != trimmed.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warranty numbers for item {item.Sku} must be unique.", 422);
|
||||
|
||||
return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
|
||||
}
|
||||
|
||||
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||
{
|
||||
if (poId is null) return;
|
||||
@@ -369,7 +437,7 @@ public sealed class GrnService : IGrnService
|
||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt,
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
|
||||
}
|
||||
|
||||
@@ -378,9 +446,11 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
|
||||
l.HoldStatus, l.BatchId)).ToList());
|
||||
l.HoldStatus, l.BatchId,
|
||||
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList());
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Single entry point into the external General Ledger service — the one function
|
||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
||||
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
|
||||
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// No business logic lives here yet; this pass only connects the transport.
|
||||
/// requests, forwarded verbatim) and by other ERPCore services that post directly to
|
||||
/// GL (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6). <see cref="ForwardAsync"/> stays a
|
||||
/// byte-for-byte passthrough; the three typed methods below are the first internal
|
||||
/// callers (GRN receipt + payment posting) and model only what those flows need.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerService
|
||||
{
|
||||
Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
|
||||
/// <summary>Creates and posts a balanced journal entry. Throws <see cref="ERPCore.System.Errors.DomainException"/> on rejection/unreachability.</summary>
|
||||
Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>Resolves the accounting period covering <paramref name="date"/>.</summary>
|
||||
Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct);
|
||||
|
||||
/// <summary>Lists GL's cash and bank accounts (default: both types).</summary>
|
||||
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ERPCore.Dtos.Grn;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
|
||||
public interface IGrnPaymentService
|
||||
{
|
||||
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public sealed class ItemService : IItemService
|
||||
.Select(i => new ItemListItemDto(
|
||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status))
|
||||
.ToListAsync(ct);
|
||||
@@ -113,6 +113,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
@@ -126,6 +127,8 @@ public sealed class ItemService : IItemService
|
||||
DefaultVendorId = request.DefaultVendorId,
|
||||
StockNature = request.StockNature,
|
||||
TrackingMode = request.TrackingMode,
|
||||
Warranty = request.Warranty,
|
||||
WarrantyPeriodMonths = warrantyPeriodMonths,
|
||||
TaxClass = request.TaxClass,
|
||||
SalePrice = request.SalePrice,
|
||||
ContentQty = request.ContentQty,
|
||||
@@ -172,6 +175,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||
|
||||
item.Sku = request.Sku.Trim();
|
||||
item.Name = request.Name.Trim();
|
||||
@@ -183,6 +187,8 @@ public sealed class ItemService : IItemService
|
||||
item.DefaultVendorId = request.DefaultVendorId;
|
||||
item.StockNature = request.StockNature;
|
||||
item.TrackingMode = request.TrackingMode;
|
||||
item.Warranty = request.Warranty;
|
||||
item.WarrantyPeriodMonths = warrantyPeriodMonths;
|
||||
item.TaxClass = request.TaxClass;
|
||||
item.SalePrice = request.SalePrice;
|
||||
item.ContentQty = request.ContentQty;
|
||||
@@ -326,6 +332,23 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item must declare a coverage period (one of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||
/// any value sent for one is silently dropped rather than trusted from the client.
|
||||
/// </summary>
|
||||
private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths)
|
||||
{
|
||||
if (warranty != Warranty.Warranty) return null;
|
||||
|
||||
if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value))
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation,
|
||||
$"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422);
|
||||
|
||||
return warrantyPeriodMonths;
|
||||
}
|
||||
|
||||
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -341,7 +364,7 @@ public sealed class ItemService : IItemService
|
||||
private static ItemDetailDto ToDetail(Item i) => new(
|
||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
i.BaseUomId, i.DefaultVendorId,
|
||||
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice,
|
||||
i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice,
|
||||
i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||
i.Status,
|
||||
i.ReorderSettings
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -70,4 +70,11 @@ public static class ErrorCodes
|
||||
|
||||
// General Ledger service proxy (GeneralLedgerController → external GL service, docs/12)
|
||||
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
|
||||
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
|
||||
public const string GlRequestFailed = "GL_REQUEST_FAILED";
|
||||
|
||||
// GRN payments
|
||||
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
|
||||
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
|
||||
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
|
||||
}
|
||||
|
||||
@@ -26,5 +26,10 @@
|
||||
"BaseUrl": "https://localhost:7024/api/v1/",
|
||||
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||
},
|
||||
"Grn": {
|
||||
"GlInventoryAccountCode": "1100",
|
||||
"GlVatRecoverableAccountCode": "1200",
|
||||
"GlClearingAccountCode": "2000"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, MeasureUnit, StockNature, TrackingMode } from "@/types/master-data"
|
||||
import { Item, MeasureUnit, StockNature, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
|
||||
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
||||
@@ -54,6 +54,9 @@ export default function ItemDetailPage() {
|
||||
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
|
||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||
// Not editable here — carried through unchanged so a save doesn't silently reset it.
|
||||
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
||||
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
|
||||
const [taxClass, setTaxClass] = useState("")
|
||||
// Raw string: an empty box means "no content size", which is not the same as 0.
|
||||
const [contentQty, setContentQty] = useState("")
|
||||
@@ -80,6 +83,8 @@ export default function ItemDetailPage() {
|
||||
setDefaultVendorId(data.defaultVendorId)
|
||||
setStockNature(data.stockNature)
|
||||
setTrackingMode(data.trackingMode)
|
||||
setWarranty(data.warranty)
|
||||
setWarrantyPeriodMonths(data.warrantyPeriodMonths)
|
||||
setTaxClass(data.taxClass ?? "")
|
||||
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
|
||||
setContentUnit(data.contentUnit)
|
||||
@@ -123,7 +128,7 @@ export default function ItemDetailPage() {
|
||||
item.itemId,
|
||||
{
|
||||
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
|
||||
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
|
||||
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
|
||||
taxClass: taxClass || null,
|
||||
contentQty: contentQty.trim() ? Number(contentQty) : null,
|
||||
contentUnit: contentQty.trim() ? contentUnit : null,
|
||||
|
||||
@@ -20,7 +20,18 @@ import {
|
||||
validateVariantPrices,
|
||||
} from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
|
||||
import {
|
||||
Brand,
|
||||
Category,
|
||||
ItemType,
|
||||
MeasureUnit,
|
||||
ProductConfig,
|
||||
StockNature,
|
||||
SubCategory,
|
||||
WARRANTY_PERIOD_MONTHS_OPTIONS,
|
||||
Warranty,
|
||||
WarrantyPeriodMonths,
|
||||
} from "@/types/master-data"
|
||||
|
||||
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
||||
@@ -125,6 +136,10 @@ export default function NewItemPage() {
|
||||
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
|
||||
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
|
||||
|
||||
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
|
||||
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
|
||||
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -340,6 +355,9 @@ export default function NewItemPage() {
|
||||
if (measurableCheckedCount > 1) {
|
||||
nextErrors.measurable = "Only one measurement dimension can be used at a time."
|
||||
}
|
||||
if (warranty === "Warranty" && warrantyPeriodMonths === null) {
|
||||
nextErrors.warrantyPeriodMonths = "Select a warranty period"
|
||||
}
|
||||
// Should never fire — addValue is the real gate — so it catches stale state only.
|
||||
const contentSweep = validateVariantContent(
|
||||
variants.map((v) => v.key),
|
||||
@@ -381,6 +399,8 @@ export default function NewItemPage() {
|
||||
baseUomId,
|
||||
stockNature,
|
||||
trackingMode: "None",
|
||||
warranty,
|
||||
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
|
||||
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
|
||||
// Each variant carries its OWN size when a measurement dimension supplied one;
|
||||
// otherwise the shared form-level pair, which is correct when the varying dimension
|
||||
@@ -672,6 +692,64 @@ export default function NewItemPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */}
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mark whether every generated variant is sold under warranty.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit rounded-lg border p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setWarranty("NonWarranty")
|
||||
setWarrantyPeriodMonths(null)
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
Non-Warranty
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWarranty("Warranty")}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
||||
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
Warranty
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{warranty === "Warranty" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Warranty period</Label>
|
||||
<Select<WarrantyPeriodMonths>
|
||||
value={warrantyPeriodMonths}
|
||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
|
||||
<SelectItem key={months} value={months} className="text-base">
|
||||
{months} months
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||
item-type reference), so this section IS the enforcement. */}
|
||||
{config?.itemTypesEnabled && (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
import { BookOpen, CheckCircle2, CircleDollarSign, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
@@ -11,15 +11,18 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
||||
import { ConfirmGrnResponse, Grn, GrnPayment } from "@/types/grn"
|
||||
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { GrnPaymentStatusBadge, GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { GrnPaymentDialog } from "@/components/receiving/GrnPaymentDialog"
|
||||
|
||||
export default function GrnDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
@@ -35,6 +38,9 @@ export default function GrnDetailPage() {
|
||||
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
|
||||
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
|
||||
|
||||
const [payments, setPayments] = useState<GrnPayment[]>([])
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
|
||||
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
|
||||
const idempotencyKey = useRef(crypto.randomUUID())
|
||||
|
||||
@@ -54,6 +60,18 @@ export default function GrnDetailPage() {
|
||||
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
|
||||
}, [grn?.warehouseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!grn || grn.status === "Draft") return
|
||||
grnsApi.listPayments(grn.grnId).then(setPayments).catch(() => setPayments([]))
|
||||
}, [grn?.grnId, grn?.status])
|
||||
|
||||
function handlePaid(_updated: Grn, payment: GrnPayment) {
|
||||
setGrn((prev) =>
|
||||
prev ? { ...prev, paidAmount: prev.paidAmount + payment.amount, balanceAmount: prev.balanceAmount - payment.amount } : prev
|
||||
)
|
||||
setPayments((prev) => [payment, ...prev])
|
||||
}
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
}
|
||||
@@ -69,8 +87,10 @@ export default function GrnDetailPage() {
|
||||
try {
|
||||
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
|
||||
setConfirmResult(result)
|
||||
setGrn((prev) => (prev ? { ...prev, status: result.status } : prev))
|
||||
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock.`)
|
||||
setGrn((prev) =>
|
||||
prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo, balanceAmount: result.balanceAmount } : prev
|
||||
)
|
||||
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock. Journal ${result.glJournalNo}.`)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not confirm GRN", errorMessage(err))
|
||||
@@ -120,6 +140,7 @@ export default function GrnDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
{grn.status !== "Draft" && <GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} — Vendor #{grn.vendorId} — Warehouse #{grn.warehouseId}
|
||||
@@ -127,14 +148,36 @@ export default function GrnDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grn.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
<PackageCheck className="size-5" />
|
||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{grn.status !== "Draft" && (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard/ledgers/general-ledger"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
title="View this GRN's postings in the General Ledger report"
|
||||
>
|
||||
<BookOpen className="size-5" />
|
||||
View in Ledger
|
||||
</Link>
|
||||
{grn.balanceAmount > 0 && (
|
||||
<Button size="lg" onClick={() => setPayDialogOpen(true)}>
|
||||
<CircleDollarSign className="size-5" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{grn.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
<PackageCheck className="size-5" />
|
||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GrnPaymentDialog grn={grn} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
@@ -149,6 +192,7 @@ export default function GrnDetailPage() {
|
||||
Layers: {confirmResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {confirmResult.ledgerRefs.join(", ")}</div>
|
||||
<div className="text-sm text-muted-foreground">GL journal entry: {confirmResult.glJournalNo}</div>
|
||||
{confirmResult.poStatus && <div className="text-sm text-muted-foreground">PO status: {confirmResult.poStatus}</div>}
|
||||
</div>
|
||||
)}
|
||||
@@ -176,7 +220,20 @@ export default function GrnDetailPage() {
|
||||
const item = itemFor(line.itemId)
|
||||
return (
|
||||
<TableRow key={line.grnLineId}>
|
||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
|
||||
</div>
|
||||
{line.warrantyNumbers.length > 0 && (
|
||||
<p
|
||||
className="mt-1 text-xs text-muted-foreground"
|
||||
title={line.warrantyNumbers.map((w) => `${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")}
|
||||
>
|
||||
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||
@@ -244,7 +301,7 @@ export default function GrnDetailPage() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-8 border-t border-border pt-4 text-base">
|
||||
<div className="flex flex-wrap justify-end gap-8 border-t border-border pt-4 text-base">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Stock value (excl. VAT)</span>
|
||||
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
|
||||
@@ -257,7 +314,49 @@ export default function GrnDetailPage() {
|
||||
<span className="text-muted-foreground">Document total</span>
|
||||
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
{grn.status !== "Draft" && (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Amount paid</span>
|
||||
<span className="tabular-nums">{formatAmount(grn.paidAmount)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Balance due</span>
|
||||
<span className="font-semibold tabular-nums">{formatAmount(grn.balanceAmount)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{grn.status !== "Draft" && payments.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Payment History</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reference</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payments.map((p) => (
|
||||
<TableRow key={p.grnPaymentId}>
|
||||
<TableCell className="px-3 py-3.5">{new Date(p.paymentDate).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.bankAccountName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.reference ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.glJournalNo ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
@@ -13,12 +13,13 @@ import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -27,8 +28,41 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
type Mode = "po" | "direct"
|
||||
type LineTab = "lines" | "warranty"
|
||||
|
||||
/** Whole units a line's qty represents — one warranty number is captured per unit. */
|
||||
function unitCount(qty: string): number {
|
||||
const n = Math.floor(Number(qty))
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */
|
||||
function WarrantyHintBubble() {
|
||||
const [visible, setVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setVisible(false), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"absolute bottom-full right-0 z-10 mb-2 flex w-max max-w-xs items-center gap-2 rounded-xl border border-info/40 bg-white p-3 shadow-xl transition-opacity duration-700",
|
||||
visible ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-info/15 text-info">
|
||||
<ShieldCheck className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 text-sm font-semibold text-info">Add warranty numbers for this item</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
@@ -42,9 +76,8 @@ interface DraftLine {
|
||||
discountPct: string
|
||||
vatPct: string
|
||||
holdStatus: HoldStatus
|
||||
batchNo: string
|
||||
expiryDate: string
|
||||
serialNumbersText: string
|
||||
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */
|
||||
warrantyNumbers: string[]
|
||||
}
|
||||
|
||||
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
|
||||
@@ -77,9 +110,7 @@ function emptyLine(): DraftLine {
|
||||
discountPct: "0",
|
||||
vatPct: "0",
|
||||
holdStatus: "Available",
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
warrantyNumbers: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +132,7 @@ export default function NewGrnPage() {
|
||||
const [poId, setPoId] = useState<number | null>(null)
|
||||
const [poLoading, setPoLoading] = useState(false)
|
||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||
@@ -174,9 +206,7 @@ export default function NewGrnPage() {
|
||||
discountPct: "0",
|
||||
vatPct: "0",
|
||||
holdStatus: "Available",
|
||||
batchNo: "",
|
||||
expiryDate: "",
|
||||
serialNumbersText: "",
|
||||
warrantyNumbers: [],
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -214,6 +244,24 @@ export default function NewGrnPage() {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
}
|
||||
|
||||
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.key !== lineKey) return l
|
||||
const next = [...l.warrantyNumbers]
|
||||
while (next.length <= index) next.push("")
|
||||
next[index] = value
|
||||
return { ...l, warrantyNumbers: next }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows
|
||||
// the "Warranty numbers" tab needs to capture, one input per unit.
|
||||
const warrantyLines = lines
|
||||
.map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) }))
|
||||
.filter((w) => w.item?.warranty === "Warranty" && w.units > 0)
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitError(null)
|
||||
setHeaderError(null)
|
||||
@@ -242,26 +290,31 @@ export default function NewGrnPage() {
|
||||
|
||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||
for (const line of lines) {
|
||||
const item = itemFor(line.itemId)
|
||||
const errors = validateLine({
|
||||
itemId: line.itemId,
|
||||
qty: line.qty,
|
||||
unitCost: line.unitCost,
|
||||
discountPct: line.discountPct,
|
||||
vatPct: line.vatPct,
|
||||
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
|
||||
batchNo: line.batchNo,
|
||||
serialNumbersText: line.serialNumbersText,
|
||||
warranty: item?.warranty ?? null,
|
||||
warrantyNumbers: line.warrantyNumbers,
|
||||
})
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
}
|
||||
setLineErrors(nextLineErrors)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
// Jump to whichever tab actually shows the offending field(s).
|
||||
const onlyWarrantyErrors = Object.values(nextLineErrors).every(
|
||||
(errs) => Object.keys(errs).every((k) => k === "warrantyNumbers")
|
||||
)
|
||||
setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
|
||||
setSubmitError("Fix the highlighted lines before submitting.")
|
||||
return
|
||||
}
|
||||
|
||||
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
|
||||
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
|
||||
const item = itemFor(l.itemId)
|
||||
return {
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId as number,
|
||||
@@ -271,8 +324,10 @@ export default function NewGrnPage() {
|
||||
discountPct: Number(l.discountPct) || 0,
|
||||
vatPct: Number(l.vatPct) || 0,
|
||||
holdStatus: l.holdStatus,
|
||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
||||
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
|
||||
warrantyNumbers:
|
||||
item?.warranty === "Warranty"
|
||||
? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -432,23 +487,49 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-fit gap-2 rounded-full border border-input bg-muted/40 p-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={lineTab === "lines" ? "default" : "ghost"}
|
||||
className="rounded-full"
|
||||
onClick={() => setLineTab("lines")}
|
||||
>
|
||||
Lines
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={lineTab === "warranty" ? "default" : "ghost"}
|
||||
className="rounded-full"
|
||||
onClick={() => setLineTab("warranty")}
|
||||
>
|
||||
Warranty numbers
|
||||
{warrantyLines.length > 0 && (
|
||||
<Badge variant="outline" className="ml-1.5 h-5 px-1.5 text-xs">
|
||||
{warrantyLines.reduce((sum, w) => sum + w.units, 0)}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{poLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
|
||||
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
|
||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -460,40 +541,47 @@ export default function NewGrnPage() {
|
||||
<TableRow key={line.key}>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{line.poLineId ? (
|
||||
<div className="flex h-11 items-center text-base">
|
||||
<div className="flex h-11 items-center gap-2 text-sm">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="text-xs">Warranty</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(items ?? []).map((i) => (
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
|
||||
{i.sku} — {i.name}
|
||||
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{item?.warranty === "Warranty" && (
|
||||
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||
)}
|
||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<div className="flex h-11 items-center text-base text-muted-foreground">
|
||||
<div className="flex h-11 items-center text-sm text-muted-foreground">
|
||||
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectTrigger className="h-11! w-full text-sm">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bins.map((b) => (
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||
<SelectItem key={b.binId} value={b.binId} className="text-sm">
|
||||
{b.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -508,7 +596,7 @@ export default function NewGrnPage() {
|
||||
value={line.qty}
|
||||
aria-invalid={!!errors.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||
</TableCell>
|
||||
@@ -520,7 +608,7 @@ export default function NewGrnPage() {
|
||||
value={line.unitCost}
|
||||
aria-invalid={!!errors.unitCost}
|
||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||
@@ -538,7 +626,7 @@ export default function NewGrnPage() {
|
||||
value={line.discountPct}
|
||||
aria-invalid={!!errors.discountPct}
|
||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||
</TableCell>
|
||||
@@ -551,7 +639,7 @@ export default function NewGrnPage() {
|
||||
value={line.vatPct}
|
||||
aria-invalid={!!errors.vatPct}
|
||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||
className="h-11 text-base"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||
</TableCell>
|
||||
@@ -573,51 +661,44 @@ export default function NewGrnPage() {
|
||||
value={line.holdStatus}
|
||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base">
|
||||
<SelectTrigger className="h-11! w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||
<SelectItem value="Available" className="text-sm">Available</SelectItem>
|
||||
<SelectItem value="OnHold" className="text-sm">On hold (inspection)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
placeholder="Batch no."
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={line.expiryDate}
|
||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||
<TableCell className="py-3 pr-0 pl-1 align-top">
|
||||
{item?.warranty === "Warranty" ? (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setLineTab("warranty")}
|
||||
aria-label="Add warranty numbers"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add warranty numbers</TooltipContent>
|
||||
</Tooltip>
|
||||
{errors.warrantyNumbers && (
|
||||
<FieldError errors={[{ message: errors.warrantyNumbers }]} />
|
||||
)}
|
||||
<WarrantyHintBubble />
|
||||
</div>
|
||||
)}
|
||||
{item?.trackingMode === "Serial" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
placeholder="One serial per line"
|
||||
value={line.serialNumbersText}
|
||||
aria-invalid={!!errors.serialNumbers}
|
||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
{(!item || item.trackingMode === "None") && (
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<TableCell className="py-3 pr-3 pl-0 align-top">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
@@ -630,7 +711,86 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!poLoading && lines.length > 0 && (
|
||||
{!poLoading && lineTab === "warranty" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit self-end rounded-full"
|
||||
onClick={() => setLineTab("lines")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to lines
|
||||
</Button>
|
||||
|
||||
{warrantyLines.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">
|
||||
No warranty-tracked items on this GRN yet — add a line for an item marked
|
||||
“Warranty” to capture its numbers here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{warrantyLines.map(({ line, item, units }) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
|
||||
<TableHead className="h-10 px-3 text-sm">Warranty number</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: units }, (_, i) => (
|
||||
<TableRow key={`${line.key}-${i}`}>
|
||||
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">
|
||||
{i + 1} of {units}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Input
|
||||
value={line.warrantyNumbers[i] ?? ""}
|
||||
aria-invalid={!!errors.warrantyNumbers}
|
||||
onChange={(e) => setWarrantyNumberAt(line.key, i, e.target.value)}
|
||||
placeholder="e.g. WTY-000123"
|
||||
className="h-10 max-w-xs text-sm"
|
||||
/>
|
||||
{i === units - 1 && (
|
||||
<FieldError
|
||||
errors={[errors.warrantyNumbers ? { message: errors.warrantyNumbers } : undefined]}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit self-end rounded-full"
|
||||
onClick={() => setLineTab("lines")}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to lines
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
@@ -644,14 +804,16 @@ export default function NewGrnPage() {
|
||||
<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/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create GRN"}
|
||||
</Button>
|
||||
</div>
|
||||
{lineTab === "lines" && (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create GRN"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "luc
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { GrnPaymentStatusBadge, GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
|
||||
type StatusFilter = GrnStatus | "All"
|
||||
|
||||
@@ -174,6 +175,8 @@ export default function GrnListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Balance</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Payment</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -196,6 +199,16 @@ export default function GrnListPage() {
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
{grn.status === "Draft" ? "—" : formatAmount(grn.balanceAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{grn.status === "Draft" ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
) : (
|
||||
<GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
This invoice is {invoice.status.toLowerCase()} and cannot be edited.
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -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 { 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<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 [submitError, setSubmitError] = useState<string | null>(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<string, string> = {}
|
||||
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() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Quantity wasted</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
type="number"
|
||||
min="0"
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Tag,
|
||||
Truck,
|
||||
Undo2,
|
||||
Users,
|
||||
Wallet,
|
||||
Warehouse,
|
||||
@@ -120,6 +121,7 @@ const navItems: {
|
||||
// { 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: "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
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
// 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
|
||||
? []
|
||||
: navItems
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
import { Grn, GrnPayment } from "@/types/grn"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface GrnPaymentDialogProps {
|
||||
grn: Grn | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPaid: (grn: Grn, payment: GrnPayment) => void
|
||||
}
|
||||
|
||||
/** Pay the vendor against a confirmed GRN's balance — full or partial, from an existing
|
||||
* GL cash/bank account. Modeled on ReceivedChequeDialog's "pick an account, submit" shape. */
|
||||
export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [reference, setReference] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Reset the form fields for a fresh open (or a different GRN) during render, React's own
|
||||
// sanctioned "adjust state while rendering" pattern (see the General Ledger report page's
|
||||
// periodKey comment) — not inside the effect below, which would be a synchronous
|
||||
// setState-in-effect (react-hooks/set-state-in-effect).
|
||||
const openKey = open && grn ? `${grn.grnId}` : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(grn!.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setReference("")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || accounts !== null) return
|
||||
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [open, accounts])
|
||||
|
||||
if (!grn) return null
|
||||
|
||||
async function submit() {
|
||||
if (!grn) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
const amountNum = Number(amount)
|
||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay from"
|
||||
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
|
||||
else if (amountNum > grn.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(grn.balanceAmount)})`
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payment = await grnsApi.pay(grn.grnId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
|
||||
onPaid(grn, payment)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error("Could not record payment", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay GRN {grn.docNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(grn.balanceAmount)}</span>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="grn-pay-account">Pay from</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="grn-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName} ({a.accountType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="grn-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="grn-pay-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={grn.balanceAmount}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-invalid={!!errors.amount}
|
||||
/>
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="grn-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="grn-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -32,3 +32,32 @@ export function HoldStatusBadge({ status }: { status: HoldStatus }) {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export type GrnPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||
|
||||
export function grnPaymentStatus(paidAmount: number, balanceAmount: number): GrnPaymentStatus {
|
||||
if (balanceAmount <= 0) return "Paid"
|
||||
if (paidAmount > 0) return "PartiallyPaid"
|
||||
return "Unpaid"
|
||||
}
|
||||
|
||||
function paymentClass(status: GrnPaymentStatus) {
|
||||
if (status === "Paid") return "bg-success/10 text-success border-transparent"
|
||||
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
|
||||
return "bg-muted text-muted-foreground border-transparent" // Unpaid
|
||||
}
|
||||
|
||||
const paymentLabel: Record<GrnPaymentStatus, string> = {
|
||||
Unpaid: "Unpaid",
|
||||
PartiallyPaid: "Partial",
|
||||
Paid: "Paid",
|
||||
}
|
||||
|
||||
export function GrnPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
|
||||
const status = grnPaymentStatus(paidAmount, balanceAmount)
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
|
||||
{paymentLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
ConfirmGrnResponse,
|
||||
CreateGrnPaymentRequest,
|
||||
CreateGrnRequest,
|
||||
Grn,
|
||||
GrnPayment,
|
||||
GrnStatus,
|
||||
GrnSummary,
|
||||
ReleaseAction,
|
||||
@@ -58,4 +60,14 @@ export const grnsApi = {
|
||||
body: { action },
|
||||
})
|
||||
},
|
||||
|
||||
/** Pay the vendor against the GRN's balance, in full or in installments. Posts a real GL journal entry. */
|
||||
pay(grnId: number, request: CreateGrnPaymentRequest): Promise<GrnPayment> {
|
||||
return apiRequest<GrnPayment>(`/grns/${grnId}/payments`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Payment history for a GRN, newest first. */
|
||||
listPayments(grnId: number): Promise<GrnPayment[]> {
|
||||
return apiRequest<GrnPayment[]>(`/grns/${grnId}/payments`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// (over-receipt tolerance, referential existence, concurrency) are never
|
||||
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
|
||||
import { z } from "zod"
|
||||
import { TrackingMode } from "@/types/master-data"
|
||||
import { Warranty } from "@/types/master-data"
|
||||
|
||||
export const grnHeaderSchema = z.object({
|
||||
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
|
||||
@@ -17,9 +17,8 @@ export function validateLine(input: {
|
||||
unitCost: string
|
||||
discountPct: string
|
||||
vatPct: string
|
||||
trackingMode: TrackingMode | null
|
||||
batchNo: string
|
||||
serialNumbersText: string
|
||||
warranty: Warranty | null
|
||||
warrantyNumbers: string[]
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
@@ -39,27 +38,16 @@ export function validateLine(input: {
|
||||
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
|
||||
errors.vatPct = "VAT must be 0–100%"
|
||||
|
||||
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
|
||||
errors.batchNo = "Batch number is required for this item"
|
||||
}
|
||||
|
||||
if (input.trackingMode === "Serial") {
|
||||
const serials = splitSerials(input.serialNumbersText)
|
||||
if (serials.length === 0) {
|
||||
errors.serialNumbers = "Enter one serial number per unit"
|
||||
} else if (!Number.isNaN(qty) && serials.length !== qty) {
|
||||
errors.serialNumbers = `Enter exactly ${qty || 0} serial number(s) — got ${serials.length}`
|
||||
} else if (new Set(serials).size !== serials.length) {
|
||||
errors.serialNumbers = "Serial numbers must be unique"
|
||||
if (input.warranty === "Warranty") {
|
||||
const numbers = input.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
if (numbers.length === 0) {
|
||||
errors.warrantyNumbers = "Enter one warranty number per unit"
|
||||
} else if (!Number.isNaN(qty) && numbers.length !== qty) {
|
||||
errors.warrantyNumbers = `Enter exactly ${qty || 0} warranty number(s) — got ${numbers.length}`
|
||||
} else if (new Set(numbers.map((s) => s.toLowerCase())).size !== numbers.length) {
|
||||
errors.warrantyNumbers = "Warranty numbers must be unique"
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export function splitSerials(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -288,6 +288,8 @@ export interface CashAndBankAccountDto {
|
||||
cashAccountTypeName: string | null
|
||||
accountNumber: string | null
|
||||
glAccountId: number
|
||||
/** The underlying GL account's business code (added GL Phase 38, 2026-08-12). */
|
||||
glAccountCode: string
|
||||
currencyCode: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ export interface BatchInput {
|
||||
expiryDate?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A captured warranty number as returned by the server. The coverage period is not chosen at
|
||||
* receipt — it is snapshotted from the item's own `warrantyPeriodMonths` (set at item create).
|
||||
*/
|
||||
export interface GrnLineWarrantyNumber {
|
||||
warrantyNo: string
|
||||
warrantyPeriodMonths: number
|
||||
}
|
||||
|
||||
/** One received line on create (docs/11 §4.1). */
|
||||
export interface CreateGrnLineInput {
|
||||
poLineId?: number | null
|
||||
@@ -40,6 +49,11 @@ export interface CreateGrnLineInput {
|
||||
vatPct?: number
|
||||
holdStatus: HoldStatus
|
||||
batch?: BatchInput | null
|
||||
/**
|
||||
* Required, one per received unit (count must equal `qty`), when the item is
|
||||
* warranty-tracked (`Item.warranty === "Warranty"`). Ignored otherwise.
|
||||
*/
|
||||
warrantyNumbers?: string[] | null
|
||||
}
|
||||
|
||||
export interface CreateGrnRequest {
|
||||
@@ -73,6 +87,7 @@ export interface GrnLine {
|
||||
priceVariance: number
|
||||
holdStatus: HoldStatus
|
||||
batchId: number | null
|
||||
warrantyNumbers: GrnLineWarrantyNumber[]
|
||||
}
|
||||
|
||||
export interface Grn {
|
||||
@@ -85,6 +100,10 @@ export interface Grn {
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
postedAt: string | null
|
||||
/** Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm). */
|
||||
glJournalNo: string | null
|
||||
paidAmount: number
|
||||
balanceAmount: number
|
||||
lines: GrnLine[]
|
||||
}
|
||||
|
||||
@@ -99,6 +118,8 @@ export interface GrnSummary {
|
||||
createdAt: string
|
||||
postedAt: string | null
|
||||
lineCount: number
|
||||
paidAmount: number
|
||||
balanceAmount: number
|
||||
}
|
||||
|
||||
/** docs/11 §4.2 confirm response. */
|
||||
@@ -117,6 +138,8 @@ export interface ConfirmGrnResponse {
|
||||
grnId: number
|
||||
status: GrnStatus
|
||||
postedAt: string
|
||||
glJournalNo: string
|
||||
balanceAmount: number
|
||||
createdLayers: CreatedLayer[]
|
||||
ledgerRefs: number[]
|
||||
poStatus: PurchaseOrderStatus | null
|
||||
@@ -132,3 +155,23 @@ export interface ReleaseGrnLineResponse {
|
||||
grnLineId: number
|
||||
holdStatus: HoldStatus
|
||||
}
|
||||
|
||||
/** A vendor payment against a confirmed GRN's balance (installments allowed). */
|
||||
export interface GrnPayment {
|
||||
grnPaymentId: number
|
||||
grnId: number
|
||||
amount: number
|
||||
paymentDate: string
|
||||
glBankAccountId: number
|
||||
bankAccountName: string
|
||||
reference: string | null
|
||||
/** Journal number of the real GL journal entry this payment posted. */
|
||||
glJournalNo: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateGrnPaymentRequest {
|
||||
amount: number
|
||||
glBankAccountId: number
|
||||
reference?: string | null
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ import { EntityStatus } from "@/types/common"
|
||||
*/
|
||||
export type StockNature = "Stocked" | "NonStocked" | "Service"
|
||||
export type TrackingMode = "None" | "Batch" | "Serial"
|
||||
export type Warranty = "NonWarranty" | "Warranty"
|
||||
|
||||
/** Warranty coverage lengths offered when an item is marked Warranty. */
|
||||
export const WARRANTY_PERIOD_MONTHS_OPTIONS = [3, 6, 12, 18] as const
|
||||
export type WarrantyPeriodMonths = (typeof WARRANTY_PERIOD_MONTHS_OPTIONS)[number]
|
||||
|
||||
/**
|
||||
* Unit of an item's content size — how much one stocked pack holds.
|
||||
@@ -40,6 +45,9 @@ export interface ItemListItem extends ItemContent {
|
||||
defaultVendorId: number | null
|
||||
stockNature: StockNature
|
||||
trackingMode: TrackingMode
|
||||
warranty: Warranty
|
||||
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
|
||||
warrantyPeriodMonths: WarrantyPeriodMonths | null
|
||||
taxClass: string | null
|
||||
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
||||
salePrice: number | null
|
||||
@@ -72,6 +80,9 @@ export interface Item extends ItemContent {
|
||||
defaultVendorId: number | null
|
||||
stockNature: StockNature
|
||||
trackingMode: TrackingMode
|
||||
warranty: Warranty
|
||||
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
|
||||
warrantyPeriodMonths: WarrantyPeriodMonths | null
|
||||
taxClass: string | null
|
||||
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
||||
salePrice: number | null
|
||||
@@ -95,6 +106,10 @@ export interface CreateItemRequest {
|
||||
defaultVendorId?: number | null
|
||||
stockNature: StockNature
|
||||
trackingMode: TrackingMode
|
||||
/** Defaults to `NonWarranty` server-side when omitted. */
|
||||
warranty?: Warranty
|
||||
/** Required (one of `WARRANTY_PERIOD_MONTHS_OPTIONS`) when `warranty` is `"Warranty"`; ignored otherwise. */
|
||||
warrantyPeriodMonths?: WarrantyPeriodMonths | null
|
||||
taxClass?: string | null
|
||||
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
|
||||
salePrice?: number | null
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user