Compare commits

..

16 Commits

Author SHA1 Message Date
Dhananjaya99 ae6a87022d make account service with grn 2026-08-15 15:43:01 +05:30
ImanThiyanga ee9fa40edf Merge pull request 'initiated migrations fix in warranty' (#46) from fix/migrations-warrenty into Dev
Reviewed-on: #46
2026-08-13 09:42:02 +00:00
Sasanka b1b0fe4bac initiated migrations fix in warranty 2026-08-13 14:52:11 +05:30
ImanThiyanga 8555702a76 Merge branch 'production' into Dev 2026-08-13 06:52:48 +00:00
ImanThiyanga fd95e92eb1 Merge pull request 'feat: add warranty tracking for items and GRN lines' (#44) from warranty-fileds-and-ui-updates into Dev
Reviewed-on: #44
2026-08-13 06:51:10 +00:00
ImanThiyanga 6e008773db Merge pull request 'feat: add warranty tracking for items and GRN lines' (#43) from warranty-fileds-and-ui-updates into production
Reviewed-on: #43
2026-08-13 06:42:51 +00:00
Sasanka 179d5b0803 feat: add warranty tracking for items and GRN lines
- Enhanced ItemService to include warranty and warranty period months in item DTOs.
- Implemented validation for warranty periods in ItemService.
- Updated frontend item detail and new item pages to handle warranty selection and input.
- Added warranty number handling in GRN creation and detail pages.
- Introduced new GrnLineWarrantyNumber entity to capture warranty numbers for received items.
- Created Warranty enum and associated allowed months for warranty coverage.
- Updated validation logic for GRN lines to ensure warranty numbers are captured correctly.
2026-08-13 12:01:11 +05:30
ImanThiyanga 342012a321 Merge pull request 'Dev' (#42) from Dev into production
Reviewed-on: #42
2026-08-12 19:45:10 +00:00
ImanThiyanga ee6ac913f1 Merge branch 'production' into Dev 2026-08-12 19:44:58 +00:00
ImanThiyanga 2661169351 Merge pull request 'pushing migration changes from now -on' (#41) from fix/migration-issue into Dev
Reviewed-on: #41
2026-08-12 07:05:09 +00:00
ImanThiyanga 80213cf47d pushing migration changes from now -on 2026-08-12 12:32:25 +05:30
ImanThiyanga 18475fdc9f Merge pull request 'feat: add sales return functionality' (#40) from sales-return into Dev
Reviewed-on: #40
2026-08-12 06:28:09 +00:00
ImanThiyanga 9a32c5c609 Merge branch 'Dev' into sales-return 2026-08-12 06:26:49 +00:00
ImanThiyanga 32f40e9d1a Merge pull request 'sku and item fix' (#39) from fix/uom-overall into Dev
Reviewed-on: #39
2026-08-12 05:32:49 +00:00
Sasanka d16a227b54 feat: add sales return functionality
- Introduced Sales Return types and interfaces in the frontend for managing sales returns.
- Implemented SalesReturnsController in the backend to handle sales return endpoints.
- Created SalesReturn and SalesReturnLine entities to represent sales return data.
- Added DTOs for sales return responses and requests.
- Configured Entity Framework for SalesReturn and SalesReturnLine entities.
- Developed ISalesReturnService interface and its implementation for business logic.
- Added API methods for listing sales returns, creating new returns, and fetching remaining returnable quantities.
- Created frontend components for creating and listing sales returns, including validation logic.
- Implemented UI for selecting invoices, reason codes, and managing return lines.
2026-08-12 10:18:56 +05:30
ImanThiyanga 7219480ca0 Merge pull request 'Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev' (#31) from Dev into production
Reviewed-on: #31
2026-08-05 07:22:32 +00:00
60 changed files with 42864 additions and 145 deletions
+1 -4
View File
@@ -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/
+25 -1
View File
@@ -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);
}
}
+1
View File
@@ -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";
}
+10
View File
@@ -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; }
}
+3
View File
@@ -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; }
}
+17
View File
@@ -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 };
}
+24 -4
View File
@@ -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
+8
View File
@@ -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
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");
}
}
}
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
+2
View File
@@ -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 §56)
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;
}
}
+27
View File
@@ -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);
}
+77 -7
View File
@@ -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);
}
+25 -2
View File
@@ -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";
}
+5
View File
@@ -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>
)
}
+12
View File
@@ -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 })
},
}
+11 -23
View File
@@ -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 0100%"
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
}
+43
View File
@@ -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
}
+15
View File
@@ -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
+58
View File
@@ -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[]
}