make account service with grn
This commit is contained in:
@@ -11,8 +11,13 @@ namespace ERPCore.Controllers;
|
|||||||
public sealed class GrnsController : ApiControllerBase
|
public sealed class GrnsController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGrnService _grns;
|
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>
|
/// <summary>List GRNs, newest first.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -59,4 +64,23 @@ public sealed class GrnsController : ApiControllerBase
|
|||||||
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||||
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||||
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,18 @@ public class Grn
|
|||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? PostedAt { 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>
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||||
public uint RowVersion { get; set; }
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
||||||
|
public ICollection<GrnPayment> Payments { get; set; } = new List<GrnPayment>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -16,23 +16,36 @@ public sealed record GrnLineDto(
|
|||||||
|
|
||||||
public sealed record GrnDto(
|
public sealed record GrnDto(
|
||||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
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>
|
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||||
public sealed record GrnSummaryDto(
|
public sealed record GrnSummaryDto(
|
||||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
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(
|
public sealed record CreatedLayerDto(
|
||||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||||
|
|
||||||
public sealed record GrnConfirmResultDto(
|
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);
|
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||||
|
|
||||||
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
|
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 ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
public sealed class BatchInput
|
public sealed class BatchInput
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
|||||||
|
|
||||||
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
builder.Property(g => g.CreatedAt).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.Property(g => g.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
|
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 sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<GrnLine> builder)
|
public void Configure(EntityTypeBuilder<GrnLine> builder)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public class ErpDbContext : DbContext
|
|||||||
// --- Goods Receipt (docs/10 Part C.3) ---
|
// --- Goods Receipt (docs/10 Part C.3) ---
|
||||||
public DbSet<Grn> Grns => Set<Grn>();
|
public DbSet<Grn> Grns => Set<Grn>();
|
||||||
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
|
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
|
||||||
|
public DbSet<GrnPayment> GrnPayments => Set<GrnPayment>();
|
||||||
|
|
||||||
// --- Batch / Serial (docs/10 Part C.4) ---
|
// --- Batch / Serial (docs/10 Part C.4) ---
|
||||||
public DbSet<Batch> Batches => Set<Batch>();
|
public DbSet<Batch> Batches => Set<Batch>();
|
||||||
|
|||||||
+7188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGrnGlPostingAndPayments : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "BalanceAmount",
|
||||||
|
table: "grns",
|
||||||
|
type: "numeric(18,4)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "GlJournalNo",
|
||||||
|
table: "grns",
|
||||||
|
type: "character varying(30)",
|
||||||
|
maxLength: 30,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "GlPostedAt",
|
||||||
|
table: "grns",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "PaidAmount",
|
||||||
|
table: "grns",
|
||||||
|
type: "numeric(18,4)",
|
||||||
|
precision: 18,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "grn_payments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
GrnPaymentId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
GrnId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
PaymentDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
GlBankAccountId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
BankAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||||
|
CreatedBy = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_grn_payments", x => x.GrnPaymentId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_grn_payments_grns_GrnId",
|
||||||
|
column: x => x.GrnId,
|
||||||
|
principalTable: "grns",
|
||||||
|
principalColumn: "GrnId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_grn_payments_users_CreatedBy",
|
||||||
|
column: x => x.CreatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_grn_payments_CreatedBy",
|
||||||
|
table: "grn_payments",
|
||||||
|
column: "CreatedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_grn_payments_GrnId",
|
||||||
|
table: "grn_payments",
|
||||||
|
column: "GrnId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "grn_payments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BalanceAmount",
|
||||||
|
table: "grns");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GlJournalNo",
|
||||||
|
table: "grns");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GlPostedAt",
|
||||||
|
table: "grns");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PaidAmount",
|
||||||
|
table: "grns");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1406,6 +1406,10 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
|
||||||
|
|
||||||
|
b.Property<decimal>("BalanceAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -1417,6 +1421,17 @@ namespace ERPCore.Migrations
|
|||||||
.HasMaxLength(30)
|
.HasMaxLength(30)
|
||||||
.HasColumnType("character varying(30)");
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("GlJournalNo")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("GlPostedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("PaidAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
b.Property<int?>("PoId")
|
b.Property<int?>("PoId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -1537,6 +1552,55 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("grn_lines", (string)null);
|
b.ToTable("grn_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("GrnPaymentId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnPaymentId"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("BankAccountName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CreatedBy")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<long>("GlBankAccountId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("GlJournalNo")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<int>("GrnId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("PaymentDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Reference")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("GrnPaymentId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedBy");
|
||||||
|
|
||||||
|
b.HasIndex("GrnId");
|
||||||
|
|
||||||
|
b.ToTable("grn_payments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("GrnLineWarrantyNumberId")
|
b.Property<int>("GrnLineWarrantyNumberId")
|
||||||
@@ -3999,6 +4063,87 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("sales_return_lines", (string)null);
|
b.ToTable("sales_return_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ReturnId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CreatedBy")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("DocNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<int>("ReasonCodeId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("WarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ReturnId");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedBy");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("DocNo")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("ReasonCodeId");
|
||||||
|
|
||||||
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("sales_returns", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ReturnLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnLineId"));
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("Qty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("ReturnId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SalesInvoiceLineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ReturnLineId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("ReturnId");
|
||||||
|
|
||||||
|
b.HasIndex("SalesInvoiceLineId");
|
||||||
|
|
||||||
|
b.ToTable("sales_return_lines", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("SalesSlipId")
|
b.Property<int>("SalesSlipId")
|
||||||
@@ -5831,6 +5976,25 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("PoLine");
|
b.Navigation("PoLine");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Grn", "Grn")
|
||||||
|
.WithMany("Payments")
|
||||||
|
.HasForeignKey("GrnId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
|
||||||
|
b.Navigation("Grn");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
||||||
@@ -6528,6 +6692,67 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("SalesInvoiceLine");
|
b.Navigation("SalesInvoiceLine");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ReasonCodeId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Creator");
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("ReasonCode");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("ReturnId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SalesInvoiceLineId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("Return");
|
||||||
|
|
||||||
|
b.Navigation("SalesInvoiceLine");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||||
@@ -7025,6 +7250,13 @@ namespace ERPCore.Migrations
|
|||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
|
|
||||||
|
b.Navigation("Payments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("WarrantyNumbers");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||||
@@ -7111,6 +7343,11 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
|
|||||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||||
builder.Services.AddScoped<IStockService, StockService>();
|
builder.Services.AddScoped<IStockService, StockService>();
|
||||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||||
|
builder.Services.AddScoped<IGrnPaymentService, GrnPaymentService>();
|
||||||
|
|
||||||
// Sales (Phase 1)
|
// Sales (Phase 1)
|
||||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using ERPCore.Infra.Gl;
|
using ERPCore.Infra.Gl;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
/// <inheritdoc cref="IGeneralLedgerService"/>
|
||||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
private readonly IGeneralLedgerClient _client;
|
private readonly IGeneralLedgerClient _client;
|
||||||
|
|
||||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
||||||
@@ -13,4 +23,89 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
|||||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
||||||
|
|
||||||
|
public async Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(request, JsonOptions));
|
||||||
|
var response = await _client.SendAsync(HttpMethod.Post, "journal-entries", null, "application/json", body, ct);
|
||||||
|
var data = ParseSuccess<GlJournalEntryData>(response);
|
||||||
|
return new GlJournalEntryResult(data.JournalNo, data.IsPosted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync(
|
||||||
|
HttpMethod.Get, "fiscal-years/periods/by-date", $"?date={date:yyyy-MM-dd}", null, null, ct);
|
||||||
|
var data = ParseSuccess<GlPeriodData>(response);
|
||||||
|
return new GlPeriod(data.PeriodId, data.FiscalYearId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var response = await _client.SendAsync(HttpMethod.Get, "bank-accounts", "?accountType=Both", null, null, ct);
|
||||||
|
var data = ParseSuccess<List<GlBankAccountData>>(response);
|
||||||
|
return data.Select(a => new GlBankAccount(
|
||||||
|
a.AccountType, a.AccountId, a.AccountName, a.BankName,
|
||||||
|
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses GL's <c>ApiResponse</c> envelope (case-insensitively, since GL's success bodies are
|
||||||
|
/// camelCase and its error bodies are PascalCase — docs/12 §4) and throws
|
||||||
|
/// <see cref="DomainException"/> if the call didn't succeed.
|
||||||
|
/// </summary>
|
||||||
|
private static T ParseSuccess<T>(GeneralLedgerResponse response)
|
||||||
|
{
|
||||||
|
GlEnvelope<T>? envelope;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
envelope = JsonSerializer.Deserialize<GlEnvelope<T>>(response.Body, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
envelope = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode is < 200 or >= 300 || envelope is null || !envelope.Success || envelope.Data is null)
|
||||||
|
{
|
||||||
|
var message = envelope?.Message ?? "The General Ledger service rejected the request.";
|
||||||
|
var status = response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502;
|
||||||
|
throw new DomainException(ErrorCodes.GlRequestFailed, message, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GlEnvelope<T>
|
||||||
|
{
|
||||||
|
public int StatusCode { get; set; }
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
public T? Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GlJournalEntryData
|
||||||
|
{
|
||||||
|
public string JournalNo { get; set; } = string.Empty;
|
||||||
|
public bool IsPosted { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GlPeriodData
|
||||||
|
{
|
||||||
|
public int PeriodId { get; set; }
|
||||||
|
public int FiscalYearId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GlBankAccountData
|
||||||
|
{
|
||||||
|
public string AccountType { get; set; } = string.Empty;
|
||||||
|
public long AccountId { get; set; }
|
||||||
|
public string AccountName { get; set; } = string.Empty;
|
||||||
|
public string? BankName { get; set; }
|
||||||
|
public string? CashAccountTypeName { get; set; }
|
||||||
|
public string? AccountNumber { get; set; }
|
||||||
|
public long GlAccountId { get; set; }
|
||||||
|
public string GlAccountCode { get; set; } = string.Empty;
|
||||||
|
public string CurrencyCode { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ERPCore.Services.Gl;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purpose-built request/response shapes for the specific GL endpoints ERPCore's GRN
|
||||||
|
/// module calls directly (journal-entries, period lookup, bank-account listing) — not
|
||||||
|
/// a full model of GL's contract (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6: typed
|
||||||
|
/// DTOs are added only for the flow that actually needs them).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GlJournalEntryLineRequest(string AccountCode, decimal DebitAmount, decimal CreditAmount, string? Memo = null);
|
||||||
|
|
||||||
|
public sealed class GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
public int PeriodId { get; set; }
|
||||||
|
public DateOnly EntryDate { get; set; }
|
||||||
|
public string? SourceModule { get; set; }
|
||||||
|
public string? Reference { get; set; }
|
||||||
|
public string? Narration { get; set; }
|
||||||
|
public List<GlJournalEntryLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record GlJournalEntryResult(string JournalNo, bool IsPosted);
|
||||||
|
|
||||||
|
public sealed record GlPeriod(int PeriodId, int FiscalYearId);
|
||||||
|
|
||||||
|
public sealed record GlBankAccount(
|
||||||
|
string AccountType, long AccountId, string AccountName, string? BankName,
|
||||||
|
string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode);
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Grn;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
|
||||||
|
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
|
||||||
|
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
|
||||||
|
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
|
||||||
|
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GrnPaymentService : IGrnPaymentService
|
||||||
|
{
|
||||||
|
private readonly IRepository<Grn> _grns;
|
||||||
|
private readonly IRepository<GrnPayment> _payments;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly string _glClearingAccountCode;
|
||||||
|
|
||||||
|
public GrnPaymentService(
|
||||||
|
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
|
||||||
|
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_grns = grns;
|
||||||
|
_payments = payments;
|
||||||
|
_gl = gl;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_uow = uow;
|
||||||
|
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||||
|
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||||
|
|
||||||
|
if (grn.Status == GrnStatus.Draft)
|
||||||
|
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
|
||||||
|
if (request.Amount > grn.BalanceAmount)
|
||||||
|
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
|
||||||
|
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
|
||||||
|
|
||||||
|
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||||
|
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
|
||||||
|
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
|
||||||
|
|
||||||
|
var actor = _currentUser.AuditUserId;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
|
||||||
|
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||||
|
// the whole payment back with no partial local state.
|
||||||
|
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||||
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
|
{
|
||||||
|
PeriodId = period.PeriodId,
|
||||||
|
EntryDate = DateOnly.FromDateTime(now),
|
||||||
|
SourceModule = "GRN_PAYMENT",
|
||||||
|
Reference = grn.DocNo,
|
||||||
|
Narration = $"Payment against GRN {grn.DocNo}",
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
|
||||||
|
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
|
||||||
|
]
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
var entity = new GrnPayment
|
||||||
|
{
|
||||||
|
GrnId = grn.GrnId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
PaymentDate = now,
|
||||||
|
GlBankAccountId = account.AccountId,
|
||||||
|
BankAccountName = account.AccountName,
|
||||||
|
Reference = request.Reference,
|
||||||
|
GlJournalNo = posted.JournalNo,
|
||||||
|
CreatedBy = actor,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
await _payments.AddAsync(entity, token);
|
||||||
|
|
||||||
|
grn.PaidAmount += request.Amount;
|
||||||
|
grn.BalanceAmount -= request.Amount;
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
return Map(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
|
||||||
|
throw new NotFoundException($"GRN {grnId} was not found.");
|
||||||
|
|
||||||
|
return await _payments.Query().AsNoTracking()
|
||||||
|
.Where(p => p.GrnId == grnId)
|
||||||
|
.OrderByDescending(p => p.GrnPaymentId)
|
||||||
|
.Select(p => new GrnPaymentDto(
|
||||||
|
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||||
|
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GrnPaymentDto Map(GrnPayment p) => new(
|
||||||
|
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||||
|
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Grn;
|
|||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -38,13 +39,18 @@ public sealed class GrnService : IGrnService
|
|||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly string _glInventoryAccountCode;
|
||||||
|
private readonly string _glVatRecoverableAccountCode;
|
||||||
|
private readonly string _glClearingAccountCode;
|
||||||
|
|
||||||
public GrnService(
|
public GrnService(
|
||||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
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;
|
_grns = grns;
|
||||||
_pos = pos;
|
_pos = pos;
|
||||||
@@ -60,6 +66,10 @@ public sealed class GrnService : IGrnService
|
|||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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(
|
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||||
@@ -82,7 +92,7 @@ public sealed class GrnService : IGrnService
|
|||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(g => new GrnSummaryDto(
|
.Select(g => new GrnSummaryDto(
|
||||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
|
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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -260,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.Status = GrnStatus.Confirmed;
|
||||||
grn.PostedAt = now;
|
grn.PostedAt = now;
|
||||||
|
grn.GlJournalNo = posted.JournalNo;
|
||||||
|
grn.GlPostedAt = now;
|
||||||
|
grn.PaidAmount = 0m;
|
||||||
|
grn.BalanceAmount = totalPayable;
|
||||||
|
|
||||||
await UpdatePoStatusAsync(grn.PoId, token);
|
await UpdatePoStatusAsync(grn.PoId, token);
|
||||||
return 0;
|
return 0;
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
return new GrnConfirmResultDto(
|
return new GrnConfirmResultDto(
|
||||||
grn.GrnId, grn.Status, now,
|
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||||
createdLayers.Select(ToCreatedLayer).ToList(),
|
createdLayers.Select(ToCreatedLayer).ToList(),
|
||||||
ledgerRefs.Select(l => l.LedgerId).ToList(),
|
ledgerRefs.Select(l => l.LedgerId).ToList(),
|
||||||
await GetPoStatusAsync(grn.PoId, ct));
|
await GetPoStatusAsync(grn.PoId, ct));
|
||||||
@@ -395,7 +437,7 @@ public sealed class GrnService : IGrnService
|
|||||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||||
|
|
||||||
return new GrnConfirmResultDto(
|
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));
|
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +446,7 @@ public sealed class GrnService : IGrnService
|
|||||||
|
|
||||||
private static GrnDto Map(Grn g) => new(
|
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.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(
|
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.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.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
using ERPCore.Infra.Gl;
|
using ERPCore.Infra.Gl;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single entry point into the external General Ledger service — the one function
|
/// Single entry point into the external General Ledger service — the one function
|
||||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
||||||
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
|
/// requests, forwarded verbatim) and by other ERPCore services that post directly to
|
||||||
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
/// GL (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6). <see cref="ForwardAsync"/> stays a
|
||||||
/// No business logic lives here yet; this pass only connects the transport.
|
/// 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>
|
/// </summary>
|
||||||
public interface IGeneralLedgerService
|
public interface IGeneralLedgerService
|
||||||
{
|
{
|
||||||
Task<GeneralLedgerResponse> ForwardAsync(
|
Task<GeneralLedgerResponse> ForwardAsync(
|
||||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
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);
|
||||||
|
}
|
||||||
@@ -70,4 +70,11 @@ public static class ErrorCodes
|
|||||||
|
|
||||||
// General Ledger service proxy (GeneralLedgerController → external GL service, docs/12)
|
// General Ledger service proxy (GeneralLedgerController → external GL service, docs/12)
|
||||||
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
|
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
|
||||||
|
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
|
||||||
|
public const string GlRequestFailed = "GL_REQUEST_FAILED";
|
||||||
|
|
||||||
|
// GRN payments
|
||||||
|
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
|
||||||
|
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
|
||||||
|
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,5 +26,10 @@
|
|||||||
"BaseUrl": "https://localhost:7024/api/v1/",
|
"BaseUrl": "https://localhost:7024/api/v1/",
|
||||||
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||||
},
|
},
|
||||||
|
"Grn": {
|
||||||
|
"GlInventoryAccountCode": "1100",
|
||||||
|
"GlVatRecoverableAccountCode": "1200",
|
||||||
|
"GlClearingAccountCode": "2000"
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
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 { grnsApi } from "@/lib/api/grns"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
@@ -11,8 +11,9 @@ import { itemsApi } from "@/lib/api/items"
|
|||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { formatAmount } from "@/lib/format"
|
||||||
import { cn } from "@/lib/utils"
|
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 { Bin, ItemListItem, Uom } from "@/types/master-data"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -20,7 +21,8 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
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() {
|
export default function GrnDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
@@ -36,6 +38,9 @@ export default function GrnDetailPage() {
|
|||||||
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
|
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
|
||||||
const [releasingLineId, setReleasingLineId] = useState<number | 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.
|
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
|
||||||
const idempotencyKey = useRef(crypto.randomUUID())
|
const idempotencyKey = useRef(crypto.randomUUID())
|
||||||
|
|
||||||
@@ -55,6 +60,18 @@ export default function GrnDetailPage() {
|
|||||||
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
|
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [grn?.warehouseId])
|
}, [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) {
|
function itemFor(itemId: number) {
|
||||||
return items.find((i) => i.itemId === itemId)
|
return items.find((i) => i.itemId === itemId)
|
||||||
}
|
}
|
||||||
@@ -70,8 +87,10 @@ export default function GrnDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
|
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
|
||||||
setConfirmResult(result)
|
setConfirmResult(result)
|
||||||
setGrn((prev) => (prev ? { ...prev, status: result.status } : prev))
|
setGrn((prev) =>
|
||||||
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock.`)
|
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) {
|
} catch (err) {
|
||||||
setError(errorMessage(err))
|
setError(errorMessage(err))
|
||||||
toast.error("Could not confirm GRN", errorMessage(err))
|
toast.error("Could not confirm GRN", errorMessage(err))
|
||||||
@@ -121,6 +140,7 @@ export default function GrnDetailPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
|
||||||
<GrnStatusBadge status={grn.status} />
|
<GrnStatusBadge status={grn.status} />
|
||||||
|
{grn.status !== "Draft" && <GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} — Vendor #{grn.vendorId} — Warehouse #{grn.warehouseId}
|
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} — Vendor #{grn.vendorId} — Warehouse #{grn.warehouseId}
|
||||||
@@ -128,14 +148,36 @@ export default function GrnDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{grn.status === "Draft" && (
|
<div className="flex items-center gap-2">
|
||||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
{grn.status !== "Draft" && (
|
||||||
<PackageCheck className="size-5" />
|
<>
|
||||||
{confirming ? "Confirming…" : "Confirm GRN"}
|
<Link
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
|
<GrnPaymentDialog grn={grn} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
@@ -150,6 +192,7 @@ export default function GrnDetailPage() {
|
|||||||
Layers: {confirmResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
|
Layers: {confirmResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Ledger refs: {confirmResult.ledgerRefs.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>}
|
{confirmResult.poStatus && <div className="text-sm text-muted-foreground">PO status: {confirmResult.poStatus}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -258,7 +301,7 @@ export default function GrnDetailPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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">
|
<div className="flex gap-3">
|
||||||
<span className="text-muted-foreground">Stock value (excl. VAT)</span>
|
<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>
|
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
|
||||||
@@ -271,7 +314,49 @@ export default function GrnDetailPage() {
|
|||||||
<span className="text-muted-foreground">Document total</span>
|
<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>
|
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "luc
|
|||||||
|
|
||||||
import { grnsApi } from "@/lib/api/grns"
|
import { grnsApi } from "@/lib/api/grns"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { formatAmount } from "@/lib/format"
|
||||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
import { GrnPaymentStatusBadge, GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||||
|
|
||||||
type StatusFilter = GrnStatus | "All"
|
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">Vendor</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Warehouse</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">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">Created</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -196,6 +199,16 @@ export default function GrnListPage() {
|
|||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<GrnStatusBadge status={grn.status} />
|
<GrnStatusBadge status={grn.status} />
|
||||||
</TableCell>
|
</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">{new Date(grn.createdAt).toLocaleString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -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>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GrnPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||||
|
|
||||||
|
export function grnPaymentStatus(paidAmount: number, balanceAmount: number): GrnPaymentStatus {
|
||||||
|
if (balanceAmount <= 0) return "Paid"
|
||||||
|
if (paidAmount > 0) return "PartiallyPaid"
|
||||||
|
return "Unpaid"
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentClass(status: GrnPaymentStatus) {
|
||||||
|
if (status === "Paid") return "bg-success/10 text-success border-transparent"
|
||||||
|
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
|
||||||
|
return "bg-muted text-muted-foreground border-transparent" // Unpaid
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentLabel: Record<GrnPaymentStatus, string> = {
|
||||||
|
Unpaid: "Unpaid",
|
||||||
|
PartiallyPaid: "Partial",
|
||||||
|
Paid: "Paid",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GrnPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
|
||||||
|
const status = grnPaymentStatus(paidAmount, balanceAmount)
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
|
||||||
|
{paymentLabel[status]}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { apiRequest, buildQuery } from "@/lib/api-client"
|
|||||||
import { PagedResponse } from "@/types/common"
|
import { PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
ConfirmGrnResponse,
|
ConfirmGrnResponse,
|
||||||
|
CreateGrnPaymentRequest,
|
||||||
CreateGrnRequest,
|
CreateGrnRequest,
|
||||||
Grn,
|
Grn,
|
||||||
|
GrnPayment,
|
||||||
GrnStatus,
|
GrnStatus,
|
||||||
GrnSummary,
|
GrnSummary,
|
||||||
ReleaseAction,
|
ReleaseAction,
|
||||||
@@ -58,4 +60,14 @@ export const grnsApi = {
|
|||||||
body: { action },
|
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`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,8 @@ export interface CashAndBankAccountDto {
|
|||||||
cashAccountTypeName: string | null
|
cashAccountTypeName: string | null
|
||||||
accountNumber: string | null
|
accountNumber: string | null
|
||||||
glAccountId: number
|
glAccountId: number
|
||||||
|
/** The underlying GL account's business code (added GL Phase 38, 2026-08-12). */
|
||||||
|
glAccountCode: string
|
||||||
currencyCode: string
|
currencyCode: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export interface Grn {
|
|||||||
createdBy: number
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
postedAt: string | null
|
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[]
|
lines: GrnLine[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +118,8 @@ export interface GrnSummary {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
postedAt: string | null
|
postedAt: string | null
|
||||||
lineCount: number
|
lineCount: number
|
||||||
|
paidAmount: number
|
||||||
|
balanceAmount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** docs/11 §4.2 confirm response. */
|
/** docs/11 §4.2 confirm response. */
|
||||||
@@ -132,6 +138,8 @@ export interface ConfirmGrnResponse {
|
|||||||
grnId: number
|
grnId: number
|
||||||
status: GrnStatus
|
status: GrnStatus
|
||||||
postedAt: string
|
postedAt: string
|
||||||
|
glJournalNo: string
|
||||||
|
balanceAmount: number
|
||||||
createdLayers: CreatedLayer[]
|
createdLayers: CreatedLayer[]
|
||||||
ledgerRefs: number[]
|
ledgerRefs: number[]
|
||||||
poStatus: PurchaseOrderStatus | null
|
poStatus: PurchaseOrderStatus | null
|
||||||
@@ -147,3 +155,23 @@ export interface ReleaseGrnLineResponse {
|
|||||||
grnLineId: number
|
grnLineId: number
|
||||||
holdStatus: HoldStatus
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user