Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
96 changed files with 560 additions and 47890 deletions
+1 -25
View File
@@ -11,13 +11,8 @@ namespace ERPCore.Controllers;
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
private readonly IGrnPaymentService _payments;
public GrnsController(IGrnService grns, IGrnPaymentService payments)
{
_grns = grns;
_payments = payments;
}
public GrnsController(IGrnService grns) => _grns = grns;
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
@@ -64,23 +59,4 @@ 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));
}
@@ -1,50 +0,0 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
[Route("api/v1/sales-day-end")]
public sealed class SalesDayEndController : ApiControllerBase
{
private readonly ISalesDayEndService _dayEnds;
public SalesDayEndController(ISalesDayEndService dayEnds) => _dayEnds = dayEnds;
/// <summary>List day-end closes, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesDayEndSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesDayEndSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? cashierUserId, CancellationToken ct)
=> Ok(await _dayEnds.ListAsync(query, cashierUserId, ct));
/// <summary>What closing this cashier/date right now would include (or the existing close's totals, if already closed).</summary>
[HttpGet("preview")]
[ProducesResponseType(typeof(SalesDayEndPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesDayEndPreviewDto>> Preview(
[FromQuery] int cashierUserId, [FromQuery] DateOnly? businessDate, CancellationToken ct)
=> Ok(await _dayEnds.PreviewAsync(cashierUserId, businessDate ?? DateOnly.FromDateTime(DateTime.UtcNow), ct));
[HttpGet("{salesDayEndId:int}")]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesDayEndDto>> GetById(int salesDayEndId, CancellationToken ct)
{
var dto = await _dayEnds.GetAsync(salesDayEndId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Close the day: lock every Posted slip for (CashierUserId, BusinessDate) and post the
/// consolidated GL journal entry. Idempotent — closing an already-closed date replays it.</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesDayEndDto>> Close([FromBody] CreateSalesDayEndRequest request, CancellationToken ct)
{
var dto = await _dayEnds.CloseAsync(request, ct);
return Created($"/api/v1/sales-day-end/{dto.SalesDayEndId}", dto);
}
}
@@ -10,13 +10,8 @@ namespace ERPCore.Controllers;
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
@@ -75,23 +70,4 @@ public sealed class SalesInvoicesController : ApiControllerBase
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> ListPayments(int salesInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(salesInvoiceId, ct));
}
-3
View File
@@ -21,7 +21,4 @@ public static class DocumentTypes
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string SalesReturn = "SRET";
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
public const string SalesDayEnd = "DEND";
}
@@ -29,11 +29,5 @@ public class BundleSale
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
/// a bundle sale is a cashier document exactly like a sales slip, just priced as a set.</summary>
public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; }
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
}
-10
View File
@@ -30,18 +30,8 @@ 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>();
}
@@ -1,31 +0,0 @@
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; }
}
@@ -28,12 +28,5 @@ public class PurchaseReturn
public DateTime CreatedAt { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
/// Reverses the Inventory/Clearing lines a Grn posts — <see cref="PurchaseReturnLine"/> carries no unit
/// price/VAT of its own, so it can't reverse a VAT-recoverable line the way a real credit note would.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
}
@@ -1,46 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A cashier's end-of-day close-out. Aggregates every <see cref="SalesSlip"/> the
/// cashier posted on <see cref="BusinessDate"/>, freezes them against this record
/// (<see cref="SalesSlip.DayEndId"/>) so a slip can only ever belong to one day-end,
/// and posts one consolidated GL journal entry for the day's revenue/tax/COGS —
/// the sales-side equivalent of how <see cref="Grn"/> posts per receipt. Unique per
/// (CashierUserId, BusinessDate): closing twice replays the existing record instead
/// of creating a second one (same idempotent-replay pattern as Grn.ConfirmAsync).
/// </summary>
public class SalesDayEnd
{
public int SalesDayEndId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int CashierUserId { get; set; }
public User? CashierUser { get; set; }
/// <summary>The calendar date being closed (matched against each slip's <see cref="SalesSlip.SlipDate"/>, UTC).</summary>
public DateOnly BusinessDate { get; set; }
public int SlipCount { get; set; }
public int BundleCount { get; set; }
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
/// <summary>Sum of the FIFO cost consumed for these slips (from <see cref="StockLedger"/>), for the COGS GL lines.</summary>
public decimal CostOfGoodsSold { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this close (null when there was nothing to post).</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int ClosedBy { get; set; }
public User? ClosedByUser { get; set; }
public DateTime ClosedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
public ICollection<SalesSlip> Slips { get; set; } = new List<SalesSlip>();
public ICollection<BundleSale> BundleSales { get; set; } = new List<BundleSale>();
}
@@ -29,14 +29,6 @@ public class SalesInvoice
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Journal number of the revenue-recognition GL journal entry posted when this
/// invoice is Posted (Debit Accounts Receivable / Credit Sales Revenue + Tax, plus COGS —
/// see SalesPostingService.PostInvoiceAsync). Unlike a SalesSlip, an invoice posts its own
/// GL entry immediately rather than waiting for Sales Day End, since invoices aren't a
/// cashier document and don't go through that close-out.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
@@ -46,5 +38,4 @@ public class SalesInvoice
public uint RowVersion { get; set; }
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
public ICollection<SalesInvoicePayment> Payments { get; set; } = new List<SalesInvoicePayment>();
}
@@ -1,32 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A customer payment made against a Posted sales invoice's balance (installments
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
/// being recorded here — the mirror image of <see cref="GrnPayment"/>.
/// </summary>
public class SalesInvoicePayment
{
public int SalesInvoicePaymentId { get; set; }
public int SalesInvoiceId { get; set; }
public SalesInvoice? SalesInvoice { 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 received into.</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; }
}
@@ -28,11 +28,5 @@ public class SalesReturn
public DateTime CreatedAt { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
/// Reverses Inventory/COGS only — <see cref="SalesReturnLine"/> carries no unit price, so revenue/tax aren't
/// reversed here (they'd need a per-line price this document doesn't capture).</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
}
@@ -27,10 +27,6 @@ public class SalesSlip
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
@@ -27,10 +27,5 @@ public class StockAdjustment
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this adjustment (set on create — it
/// auto-posts). Increases and decreases post separately (never netted) so gains/losses stay visible.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
}
+3 -16
View File
@@ -16,36 +16,23 @@ public sealed record GrnLineDto(
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
decimal PaidAmount, decimal BalanceAmount, IReadOnlyList<GrnLineDto> Lines);
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, 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,
decimal PaidAmount, decimal BalanceAmount);
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
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, string GlJournalNo, decimal BalanceAmount,
int GrnId, GrnStatus Status, DateTime PostedAt,
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
@@ -9,13 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record PurchaseReturnSummaryDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
+11 -2
View File
@@ -99,8 +99,10 @@ public sealed class CreateRunRequest
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
/// STAGE_NOT_EDITABLE</c> for every other status).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
@@ -116,4 +118,11 @@ public sealed class StageQuantityLine
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
/// <summary>
/// Required only when this line lowers a running Stock input below what it has already
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
/// </summary>
public int? ReasonCodeId { get; set; }
}
@@ -1,40 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Sales;
// Responses -----------------------------------------------------------------------
/// <summary>Qty/revenue sold for one item across a closed (or about-to-close) day, for the day-end report.
/// Merges sales-slip and bundle-sale lines for the same item into one row.</summary>
public sealed record SalesDayEndItemLineDto(int ItemId, string Sku, string Name, decimal Qty, decimal Revenue);
public sealed record SalesDayEndDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
decimal CostOfGoodsSold, string? GlJournalNo, DateTime? GlPostedAt, int ClosedBy, DateTime ClosedAt,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers, IReadOnlyList<SalesDayEndItemLineDto> ItemBreakdown);
public sealed record SalesDayEndSummaryDto(
int SalesDayEndId, string DocNo, int CashierUserId, DateOnly BusinessDate,
int SlipCount, int BundleCount, decimal GrandTotal, string? GlJournalNo, DateTime ClosedAt);
/// <summary>What closing right now would include — call before <c>POST /sales-day-end</c> so the
/// cashier can see the day's totals, and any still-Draft slips/bundles blocking the close.</summary>
public sealed record SalesDayEndPreviewDto(
int CashierUserId, DateOnly BusinessDate, bool AlreadyClosed,
int SlipCount, int BundleCount, decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal,
IReadOnlyList<string> SlipNumbers, IReadOnlyList<string> BundleNumbers,
IReadOnlyList<DraftSlipBlockingCloseDto> DraftSlipsBlockingClose,
IReadOnlyList<DraftBundleBlockingCloseDto> DraftBundleSalesBlockingClose);
public sealed record DraftSlipBlockingCloseDto(int SalesSlipId, string SlipNo, decimal GrandTotal);
public sealed record DraftBundleBlockingCloseDto(int BundleSaleId, string BundleNo, decimal GrandTotal);
// Requests ------------------------------------------------------------------------
public sealed class CreateSalesDayEndRequest
{
[Required] public int CashierUserId { get; set; }
/// <summary>Defaults to today (UTC) when omitted.</summary>
public DateOnly? BusinessDate { get; set; }
}
+2 -14
View File
@@ -17,24 +17,12 @@ public sealed record SalesInvoiceDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record SalesInvoicePaymentDto(
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreateSalesInvoicePaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
public sealed record SalesInvoicePostingIssueDto(
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
@@ -9,13 +9,12 @@ public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineI
public sealed record SalesReturnDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<SalesReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
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, string? GlJournalNo);
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty);
/// <summary>
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
+2 -3
View File
@@ -9,13 +9,12 @@ public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, in
public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
+1 -1
View File
@@ -16,7 +16,7 @@ public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
@@ -16,9 +16,6 @@ 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);
@@ -31,27 +28,6 @@ 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)
@@ -16,7 +16,6 @@ public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration<Purch
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.GlJournalNo).HasMaxLength(30);
builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
@@ -1,44 +0,0 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SalesDayEndConfiguration : IEntityTypeConfiguration<SalesDayEnd>
{
public void Configure(EntityTypeBuilder<SalesDayEnd> builder)
{
builder.ToTable("sales_day_ends");
builder.HasKey(x => x.SalesDayEndId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.BusinessDate).IsRequired();
foreach (var p in new[] { nameof(SalesDayEnd.Subtotal), nameof(SalesDayEnd.DiscountTotal), nameof(SalesDayEnd.TaxTotal), nameof(SalesDayEnd.GrandTotal), nameof(SalesDayEnd.CostOfGoodsSold) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.BundleCount).HasDefaultValue(0);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.ClosedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.ClosedByUser).WithMany().HasForeignKey(x => x.ClosedBy).OnDelete(DeleteBehavior.Restrict);
// A cashier can only close a given business date once (CloseAsync replays this
// row idempotently on a second call instead of erroring — see SalesDayEndService).
builder.HasIndex(x => new { x.CashierUserId, x.BusinessDate }).IsUnique();
builder.HasMany(x => x.Slips)
.WithOne(x => x.DayEnd)
.HasForeignKey(x => x.DayEndId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.BundleSales)
.WithOne(x => x.DayEnd)
.HasForeignKey(x => x.DayEndId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -40,7 +40,6 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
foreach (var p in new[] { nameof(SalesInvoice.Subtotal), nameof(SalesInvoice.DiscountTotal), nameof(SalesInvoice.TaxTotal), nameof(SalesInvoice.GrandTotal), nameof(SalesInvoice.RoundOff), nameof(SalesInvoice.NetPayable), nameof(SalesInvoice.PaidAmount), nameof(SalesInvoice.BalanceAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
@@ -55,27 +54,6 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
}
}
public sealed class SalesInvoicePaymentConfiguration : IEntityTypeConfiguration<SalesInvoicePayment>
{
public void Configure(EntityTypeBuilder<SalesInvoicePayment> builder)
{
builder.ToTable("sales_invoice_payments");
builder.HasKey(p => p.SalesInvoicePaymentId);
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.SalesInvoice).WithMany(x => x.Payments).HasForeignKey(p => p.SalesInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.SalesInvoiceId);
}
}
public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<SalesInvoiceLine>
{
public void Configure(EntityTypeBuilder<SalesInvoiceLine> builder)
@@ -16,7 +16,6 @@ public sealed class SalesReturnConfiguration : IEntityTypeConfiguration<SalesRet
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.GlJournalNo).HasMaxLength(30);
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);
@@ -16,7 +16,6 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
builder.Property(a => a.RowVersion).IsRowVersion();
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
@@ -63,7 +63,6 @@ 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>();
@@ -88,10 +87,8 @@ public class ErpDbContext : DbContext
// --- Sales (Phase 1) ---
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
public DbSet<SalesInvoicePayment> SalesInvoicePayments => Set<SalesInvoicePayment>();
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
public DbSet<SalesDayEnd> SalesDayEnds => Set<SalesDayEnd>();
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
File diff suppressed because it is too large Load Diff
@@ -1,113 +0,0 @@
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
@@ -1,109 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesDayEnd : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DayEndId",
table: "sales_slips",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "sales_day_ends",
columns: table => new
{
SalesDayEndId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
CashierUserId = table.Column<int>(type: "integer", nullable: false),
BusinessDate = table.Column<DateOnly>(type: "date", nullable: false),
SlipCount = table.Column<int>(type: "integer", nullable: false),
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
TaxTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GrandTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CostOfGoodsSold = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
GlJournalNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
GlPostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ClosedBy = table.Column<int>(type: "integer", nullable: false),
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_day_ends", x => x.SalesDayEndId);
table.ForeignKey(
name: "FK_sales_day_ends_users_CashierUserId",
column: x => x.CashierUserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_day_ends_users_ClosedBy",
column: x => x.ClosedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_slips_DayEndId",
table: "sales_slips",
column: "DayEndId");
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_CashierUserId_BusinessDate",
table: "sales_day_ends",
columns: new[] { "CashierUserId", "BusinessDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_ClosedBy",
table: "sales_day_ends",
column: "ClosedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_day_ends_DocNo",
table: "sales_day_ends",
column: "DocNo",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_sales_slips_sales_day_ends_DayEndId",
table: "sales_slips",
column: "DayEndId",
principalTable: "sales_day_ends",
principalColumn: "SalesDayEndId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_slips_sales_day_ends_DayEndId",
table: "sales_slips");
migrationBuilder.DropTable(
name: "sales_day_ends");
migrationBuilder.DropIndex(
name: "IX_sales_slips_DayEndId",
table: "sales_slips");
migrationBuilder.DropColumn(
name: "DayEndId",
table: "sales_slips");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,61 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddReturnGlPosting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "sales_returns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "sales_returns",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "purchase_returns",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "purchase_returns",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "sales_returns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "sales_returns");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "purchase_returns");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "purchase_returns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,40 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddAdjustmentGlPosting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "stock_adjustments",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "stock_adjustments",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "stock_adjustments");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,60 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBundleSaleToDayEnd : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BundleCount",
table: "sales_day_ends",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "DayEndId",
table: "bundle_sales",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_bundle_sales_DayEndId",
table: "bundle_sales",
column: "DayEndId");
migrationBuilder.AddForeignKey(
name: "FK_bundle_sales_sales_day_ends_DayEndId",
table: "bundle_sales",
column: "DayEndId",
principalTable: "sales_day_ends",
principalColumn: "SalesDayEndId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_bundle_sales_sales_day_ends_DayEndId",
table: "bundle_sales");
migrationBuilder.DropIndex(
name: "IX_bundle_sales_DayEndId",
table: "bundle_sales");
migrationBuilder.DropColumn(
name: "BundleCount",
table: "sales_day_ends");
migrationBuilder.DropColumn(
name: "DayEndId",
table: "bundle_sales");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,87 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesInvoicePayments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlJournalNo",
table: "sales_invoices",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GlPostedAt",
table: "sales_invoices",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "sales_invoice_payments",
columns: table => new
{
SalesInvoicePaymentId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SalesInvoiceId = 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_sales_invoice_payments", x => x.SalesInvoicePaymentId);
table.ForeignKey(
name: "FK_sales_invoice_payments_sales_invoices_SalesInvoiceId",
column: x => x.SalesInvoiceId,
principalTable: "sales_invoices",
principalColumn: "SalesInvoiceId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_invoice_payments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_payments_CreatedBy",
table: "sales_invoice_payments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_payments_SalesInvoiceId",
table: "sales_invoice_payments",
column: "SalesInvoiceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sales_invoice_payments");
migrationBuilder.DropColumn(
name: "GlJournalNo",
table: "sales_invoices");
migrationBuilder.DropColumn(
name: "GlPostedAt",
table: "sales_invoices");
}
}
}
@@ -422,9 +422,6 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("DayEndId")
.HasColumnType("integer");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -465,8 +462,6 @@ namespace ERPCore.Migrations
b.HasIndex("CustomerId");
b.HasIndex("DayEndId");
b.HasIndex("WarehouseId");
b.ToTable("bundle_sales", (string)null);
@@ -1411,10 +1406,6 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<decimal>("BalanceAmount")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1426,17 +1417,6 @@ namespace ERPCore.Migrations
.HasMaxLength(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")
.HasColumnType("integer");
@@ -1584,55 +1564,6 @@ namespace ERPCore.Migrations
b.ToTable("grn_line_warranty_numbers", (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.HrDocumentType", b =>
{
b.Property<int>("HrDocumentTypeId")
@@ -3176,13 +3107,6 @@ namespace ERPCore.Migrations
.HasMaxLength(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<int>("ReasonCodeId")
.HasColumnType("integer");
@@ -3788,85 +3712,6 @@ namespace ERPCore.Migrations
b.ToTable("hr_salary_components", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.Property<int>("SalesDayEndId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesDayEndId"));
b.Property<int>("BundleCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<DateOnly>("BusinessDate")
.HasColumnType("date");
b.Property<int>("CashierUserId")
.HasColumnType("integer");
b.Property<DateTime>("ClosedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("ClosedBy")
.HasColumnType("integer");
b.Property<decimal>("CostOfGoodsSold")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("DocNo")
.IsRequired()
.HasMaxLength(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>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<int>("SlipCount")
.HasColumnType("integer");
b.Property<decimal>("Subtotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<decimal>("TaxTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.HasKey("SalesDayEndId");
b.HasIndex("ClosedBy");
b.HasIndex("DocNo")
.IsUnique();
b.HasIndex("CashierUserId", "BusinessDate")
.IsUnique();
b.ToTable("sales_day_ends", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.Property<int>("SalesInvoiceId")
@@ -3904,13 +3749,6 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTime?>("GlPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("GrandTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -4080,55 +3918,6 @@ namespace ERPCore.Migrations
b.ToTable("sales_invoice_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
{
b.Property<int>("SalesInvoicePaymentId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesInvoicePaymentId"));
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<DateTime>("PaymentDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Reference")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SalesInvoiceId")
.HasColumnType("integer");
b.HasKey("SalesInvoicePaymentId");
b.HasIndex("CreatedBy");
b.HasIndex("SalesInvoiceId");
b.ToTable("sales_invoice_payments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.Property<int>("ReturnId")
@@ -4151,13 +3940,6 @@ namespace ERPCore.Migrations
.HasMaxLength(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<int>("ReasonCodeId")
.HasColumnType("integer");
@@ -4243,9 +4025,6 @@ namespace ERPCore.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("DayEndId")
.HasColumnType("integer");
b.Property<decimal>("DiscountTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
@@ -4299,8 +4078,6 @@ namespace ERPCore.Migrations
b.HasIndex("CustomerId");
b.HasIndex("DayEndId");
b.HasIndex("SlipDate");
b.HasIndex("SlipNo")
@@ -4562,13 +4339,6 @@ namespace ERPCore.Migrations
.HasMaxLength(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<int>("ReasonCodeId")
.HasColumnType("integer");
@@ -5757,11 +5527,6 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
.WithMany("BundleSales")
.HasForeignKey("DayEndId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -5774,8 +5539,6 @@ namespace ERPCore.Migrations
b.Navigation("Customer");
b.Navigation("DayEnd");
b.Navigation("Warehouse");
});
@@ -6079,25 +5842,6 @@ namespace ERPCore.Migrations
b.Navigation("GrnLine");
});
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.Item", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
@@ -6671,25 +6415,6 @@ namespace ERPCore.Migrations
b.Navigation("Uom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
.WithMany()
.HasForeignKey("CashierUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "ClosedByUser")
.WithMany()
.HasForeignKey("ClosedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CashierUser");
b.Navigation("ClosedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -6742,25 +6467,6 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice")
.WithMany("Payments")
.HasForeignKey("SalesInvoiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Creator");
b.Navigation("SalesInvoice");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -6836,11 +6542,6 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
.WithMany("Slips")
.HasForeignKey("DayEndId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
@@ -6851,8 +6552,6 @@ namespace ERPCore.Migrations
b.Navigation("Customer");
b.Navigation("DayEnd");
b.Navigation("Warehouse");
});
@@ -7326,8 +7025,6 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
@@ -7404,18 +7101,9 @@ namespace ERPCore.Migrations
b.Navigation("Outputs");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
{
b.Navigation("BundleSales");
b.Navigation("Slips");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.Navigation("Lines");
b.Navigation("Payments");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
-3
View File
@@ -103,7 +103,6 @@ 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>();
@@ -112,13 +111,11 @@ builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
builder.Services.AddScoped<ISalesInvoicePaymentService, SalesInvoicePaymentService>();
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>();
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
// Stock transactions + reference data (docs/11 §5–6)
builder.Services.AddScoped<IStockMutator, StockMutator>();
+3 -56
View File
@@ -6,7 +6,6 @@ using ERPCore.Dtos.Stock;
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;
@@ -17,10 +16,7 @@ namespace ERPCore.Services;
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
/// decrease, layer create on an increase) + ledger posting is delegated to
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05). Also posts
/// a real GL journal entry — increases and decreases post as separate Inventory/Gain and
/// Loss/Inventory lines respectively (never netted against each other), so a count that's
/// simultaneously over on one item and under on another shows both, not a false net.
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05).
/// </summary>
public sealed class AdjustmentService : IAdjustmentService
{
@@ -33,16 +29,11 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glGainAccountCode;
private readonly string _glLossAccountCode;
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
@@ -53,45 +44,6 @@ public sealed class AdjustmentService : IAdjustmentService
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
}
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
/// <see cref="CountService"/>'s variance posting mirrors this exact logic for the same reason
/// (it creates a <see cref="StockAdjustment"/> through the same <see cref="IStockMutator"/> call).</summary>
private async Task<string?> PostAdjustmentJournalAsync(
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
{
var gain = refs.Where(r => r.Direction == Direction.In).Sum(r => r.Value);
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
if (gain <= 0 && loss <= 0) return null;
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
var lines = new List<GlJournalEntryLineRequest>();
if (gain > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
}
if (loss > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
}
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "ADJUSTMENT",
Reference = docNo,
Narration = $"Stock adjustment {docNo}",
Lines = lines
}, ct);
return result.JournalNo;
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
@@ -112,7 +64,7 @@ public sealed class AdjustmentService : IAdjustmentService
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
a.CreatedBy, a.CreatedAt, a.Lines.Count))
.ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -184,10 +136,6 @@ public sealed class AdjustmentService : IAdjustmentService
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
return (entity, refs);
}, ct);
@@ -196,7 +144,6 @@ public sealed class AdjustmentService : IAdjustmentService
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs);
@@ -17,7 +17,6 @@ public sealed class BundleSaleService : IBundleSaleService
{
private readonly IRepository<BundleSaleTemplate> _templates;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<SalesDayEnd> _dayEnds;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Warehouse> _warehouses;
@@ -31,7 +30,6 @@ public sealed class BundleSaleService : IBundleSaleService
public BundleSaleService(
IRepository<BundleSale> bundles,
IRepository<BundleSaleTemplate> templates,
IRepository<SalesDayEnd> dayEnds,
IRepository<Customer> customers,
IRepository<Item> items,
IRepository<Warehouse> warehouses,
@@ -44,7 +42,6 @@ public sealed class BundleSaleService : IBundleSaleService
{
_templates = templates;
_bundles = bundles;
_dayEnds = dayEnds;
_customers = customers;
_items = items;
_warehouses = warehouses;
@@ -113,15 +110,6 @@ public sealed class BundleSaleService : IBundleSaleService
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
// Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document
// exactly like a sales slip, so it's blocked by the same closed day (docs/14 Sales Day End).
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var alreadyClosed = await _dayEnds.Query().AsNoTracking()
.AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct);
if (alreadyClosed)
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
var bundle = new BundleSale
+6 -55
View File
@@ -6,7 +6,6 @@ using ERPCore.Dtos.Stock;
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;
@@ -17,9 +16,7 @@ namespace ERPCore.Services;
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
/// and closes the count — all in one UoW transaction. GL posting for that variance
/// mirrors <see cref="AdjustmentService"/>'s (same account config, same gain/loss
/// split) since it's the exact same kind of document under the hood.
/// and closes the count — all in one UoW transaction.
/// </summary>
public sealed class CountService : ICountService
{
@@ -35,16 +32,11 @@ public sealed class CountService : ICountService
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glGainAccountCode;
private readonly string _glLossAccountCode;
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_counts = counts;
_adjustments = adjustments;
@@ -56,44 +48,6 @@ public sealed class CountService : ICountService
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
}
/// <summary>Same logic as <c>AdjustmentService</c>'s private method of the same name — see there for why
/// gains/losses post as separate lines instead of a net.</summary>
private async Task<string?> PostAdjustmentJournalAsync(
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
{
var gain = refs.Where(r => r.Direction == Direction.In).Sum(r => r.Value);
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
if (gain <= 0 && loss <= 0) return null;
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
var lines = new List<GlJournalEntryLineRequest>();
if (gain > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
}
if (loss > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
}
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "ADJUSTMENT",
Reference = docNo,
Narration = $"Stock adjustment {docNo}",
Lines = lines
}, ct);
return result.JournalNo;
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
@@ -206,7 +160,7 @@ public sealed class CountService : ICountService
{
count.Status = CountStatus.Posted;
await _uow.SaveChangesAsync(ct);
return new CountPostResultDto(count.CountId, count.Status, null, null, Array.Empty<int>());
return new CountPostResultDto(count.CountId, count.Status, null, Array.Empty<int>());
}
var reason = await _reasonCodes.Query().AsNoTracking()
@@ -214,7 +168,7 @@ public sealed class CountService : ICountService
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
var now = DateTime.UtcNow;
var (adjustmentId, glJournalNo, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
var (adjustmentId, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var adjustment = new StockAdjustment
@@ -237,15 +191,12 @@ public sealed class CountService : ICountService
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
count.Status = CountStatus.Posted;
return (adjustment.AdjustmentId, adjustment.GlJournalNo, refs);
return (adjustment.AdjustmentId, refs);
}, ct);
// Map ledger ids after commit so they are populated.
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, glJournalNo, ledgerEntries.Select(r => r.LedgerId).ToList());
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, ledgerEntries.Select(r => r.LedgerId).ToList());
}
private static CountDto Map(StockCount c) => new(
@@ -1,21 +1,11 @@
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;
@@ -23,89 +13,4 @@ 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
@@ -1,27 +0,0 @@
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);
@@ -1,120 +0,0 @@
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);
}
+4 -47
View File
@@ -6,7 +6,6 @@ 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;
@@ -39,18 +38,13 @@ 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,
IGeneralLedgerService gl, IConfiguration configuration)
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_grns = grns;
_pos = pos;
@@ -66,10 +60,6 @@ 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(
@@ -92,7 +82,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.PaidAmount, g.BalanceAmount))
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -270,47 +260,15 @@ 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.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, now,
createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct));
@@ -437,7 +395,7 @@ public sealed class GrnService : IGrnService
.Select(l => l.LedgerId).ToListAsync(ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
}
@@ -446,7 +404,6 @@ 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,
@@ -1,27 +1,16 @@
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 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.
/// 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.
/// </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);
}
@@ -1,10 +0,0 @@
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);
}
@@ -1,20 +0,0 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
public interface ISalesDayEndService
{
/// <summary>What closing <paramref name="businessDate"/> for this cashier would include right now
/// (or the already-closed record's totals, if it's already closed).</summary>
Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default);
/// <summary>Closes the day: locks every Posted slip for (CashierUserId, BusinessDate) against this
/// record and posts one consolidated GL journal entry. Idempotent — a second call for an
/// already-closed date replays the existing record rather than erroring.</summary>
Task<SalesDayEndDto> CloseAsync(CreateSalesDayEndRequest request, CancellationToken ct = default);
Task<PagedResponse<SalesDayEndSummaryDto>> ListAsync(PageQuery query, int? cashierUserId, CancellationToken ct = default);
Task<SalesDayEndDto?> GetAsync(int salesDayEndId, CancellationToken ct = default);
}
@@ -1,10 +0,0 @@
using ERPCore.Dtos.Sales;
namespace ERPCore.Services.Interfaces;
/// <summary>Customer payments against a Posted sales invoice's balance (installments allowed).</summary>
public interface ISalesInvoicePaymentService
{
Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default);
}
@@ -16,17 +16,6 @@ namespace ERPCore.Services.Production;
/// <summary>
/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19).
///
/// Deliberately posts no GL journal entry anywhere in this file, for the same reason
/// as <see cref="TransferService"/>: every movement here — raw material consumed
/// (<see cref="StartStageAsync"/>), finished goods received (<see cref="PostReceiptAsync"/>),
/// leftovers/cancellation returned to stock — stays inside the single shared Inventory
/// GL account (raw materials, WIP, and finished goods are not separate GL accounts in
/// this chart of accounts). And unlike a real write-off, scrap here is never removed
/// from the cost pool — <see cref="PostReceiptAsync"/> divides the full consumed value
/// by the GOOD quantity only, so a scrapped unit's cost is absorbed into the surviving
/// units' cost rather than expensed. So there is no value entering, leaving, or being
/// destroyed anywhere in a run — nothing for a journal entry to say.
/// </summary>
public sealed class ProductionRunService : IProductionRunService
{
@@ -305,17 +294,58 @@ public sealed class ProductionRunService : IProductionRunService
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
// FR-MFG-08: editable until the stage starts. Once started, consumption has
// already happened against the planned figures.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
// input can still move — raising it draws the extra amount via FIFO (mirroring
// StartStageAsync's initial draw), lowering it returns the excess to stock
// (mirroring ReturnLeftoverAsync) — but every other status stays locked.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready or ProductionStageStatus.InProgress))
throw new DomainException(ErrorCodes.StageNotEditable,
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts or while it is running.", 409);
var now = DateTime.UtcNow;
var balances = new Dictionary<int, decimal>();
var consumed = new List<ConsumedInputDto>();
var returned = new List<object>();
foreach (var line in request.Inputs)
{
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
?? throw new DomainException(ErrorCodes.Validation,
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
if (stage.Status == ProductionStageStatus.InProgress && input.Source == StageInputSource.Stock)
{
var item = await _items.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
?? throw new DomainException(ErrorCodes.Validation,
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
var plannedBase = _measure.ToPacks(item, line.PlannedQty, input.QtyUnit);
var netConsumed = input.ConsumedQty - input.ReturnedQty;
var delta = plannedBase - netConsumed;
if (delta > 0)
{
var (dto, _) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
consumed.Add(dto);
}
else if (delta < 0)
{
var qty = -delta;
if (line.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired,
$"A reason code is required to lower input '{input.RunInputId}' below what has already been consumed.", 400);
await EnsureProductionReasonAsync(line.ReasonCodeId.Value, token);
var (unitCost, returnValue, _, _) = await ReturnInputAsync(run, input, qty, balances, now, token);
returned.Add(new
{
runInputId = input.RunInputId, qty, unitCost, value = returnValue,
reasonCodeId = line.ReasonCodeId
});
}
}
input.PlannedQty = line.PlannedQty;
}
@@ -330,12 +360,15 @@ public sealed class ProductionRunService : IProductionRunService
// Raising an upstream input's planned quantity means the deliveries that made
// this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
// does not state this; it is the only behaviour consistent with FR-MFG-09.
// No-ops once the stage is running (RecomputeReadiness only moves Waiting/Ready).
RecomputeReadiness(stage);
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
{
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }),
consumed = consumed.Select(c => new { c.RunInputId, c.ItemId, c.Qty, c.Value }),
returned
});
}, ct);
@@ -344,6 +377,87 @@ public sealed class ProductionRunService : IProductionRunService
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
}
/// <summary>
/// FIFO-consumes <paramref name="delta"/> base units of a Stock input, posts the Out
/// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by
/// <see cref="StartStageAsync"/> (initial draw) and <see cref="UpdateStageQuantitiesAsync"/>
/// (raising planned qty on a running stage) so the two never drift apart.
/// </summary>
private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync(
ProductionRun run, RunStageInput input, Item item, decimal delta,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, ct);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, _currentUser.AuditUserId,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, ct);
input.ConsumedQty += delta;
input.ConsumedValue += value;
var dto = new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList());
return (dto, entry);
}
/// <summary>
/// Returns <paramref name="qty"/> base units of a Stock input's consumption back to
/// stock: a new inbound FIFO layer at the input's weighted consumed cost, plus the In
/// ledger entry. Cost-preserving — a full return zeroes the input's contribution to the
/// cost pool exactly, with no rounding crumb left behind (see the full-return branch
/// below). Shared by <see cref="ReturnLeftoverAsync"/> and
/// <see cref="UpdateStageQuantitiesAsync"/> (lowering planned qty on a running stage).
/// </summary>
private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync(
ProductionRun run, RunStageInput input, decimal qty,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
// Weighted cost actually consumed for THIS input — cost-preserving by construction,
// so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
var unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and then
// multiplying lets successive partial returns accumulate drift until the last one
// can exceed ConsumedValue.
var returnValue = input.ReturnedQty + qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the pool
// lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(qty * weighted, 4, MidpointRounding.AwayFromZero);
var layer = await _fifo.CreateInboundLayerAsync(
input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
qty, unitCost, now, ct);
if (!balances.TryGetValue(input.ItemId.Value, out var balance))
balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, ct);
balance += qty;
balances[input.ItemId.Value] = balance;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
var entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, ct, valueOverride: returnValue);
input.ReturnedQty += qty;
input.ReturnedValue += returnValue;
return (unitCost, returnValue, layer, entry);
}
// --- stage actions -------------------------------------------------------
public async Task<StartStageResultDto> StartStageAsync(
@@ -361,7 +475,6 @@ public sealed class ProductionRunService : IProductionRunService
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
var now = DateTime.UtcNow;
var actor = _currentUser.AuditUserId;
// Running balances accumulated per item across the loop: layers written in this
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
@@ -386,27 +499,9 @@ public sealed class ProductionRunService : IProductionRunService
var delta = plannedBase - input.ConsumedQty;
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
var (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
ledgerRefs.Add(entry);
input.ConsumedQty += delta;
input.ConsumedValue += value;
consumed.Add(new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
consumed.Add(dto);
}
stage.Status = ProductionStageStatus.InProgress;
@@ -649,36 +744,9 @@ public sealed class ProductionRunService : IProductionRunService
var now = DateTime.UtcNow;
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
// Weighted cost actually consumed for THIS input — cost-preserving by
// construction, so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
returnedQty = request.Qty;
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
// then multiplying lets successive partial returns accumulate drift until the
// last one can exceed ConsumedValue.
returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the
// pool lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);
layer = await _fifo.CreateInboundLayerAsync(
input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
request.Qty, unitCost, now, token);
var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);
input.ReturnedQty += request.Qty;
input.ReturnedValue += returnValue;
(unitCost, returnValue, layer, entry) =
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
@@ -6,7 +6,6 @@ using ERPCore.Dtos.Procurement;
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;
@@ -17,9 +16,7 @@ namespace ERPCore.Services;
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
/// code and generates an outbound stock movement via the shared
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction. Also posts a real GL
/// journal entry reversing the Inventory/Clearing lines the original <see cref="Grn"/>
/// posted — reuses <c>Grn</c>'s own account config since it's reversing that posting.
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction.
/// </summary>
public sealed class PurchaseReturnService : IPurchaseReturnService
{
@@ -34,15 +31,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glClearingAccountCode;
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_vendors = vendors;
@@ -55,9 +49,6 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
}
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
@@ -78,7 +69,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new PurchaseReturnSummaryDto(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.GlJournalNo))
r.CreatedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -155,28 +146,6 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.PurchaseReturn, ret.ReturnId, now, deltas, token);
var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0)
{
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE-RETURN",
Reference = docNo,
Narration = $"Purchase return {docNo} — stock returned to vendor",
Lines = new List<GlJournalEntryLineRequest>
{
new(_glClearingAccountCode, totalValue, 0m, $"Purchase return {docNo}"),
new(_glInventoryAccountCode, 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
}
}, token);
ret.GlJournalNo = glResult.JournalNo;
ret.GlPostedAt = now;
}
return (ret, refs);
}, ct);
@@ -186,7 +155,6 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.GlJournalNo, r.GlPostedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerRefs);
@@ -1,319 +0,0 @@
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.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Services;
/// <summary>
/// Cashier day-end close-out. A day's <see cref="SalesSlip"/>s and <see cref="BundleSale"/>s —
/// both cashier documents with the same Draft→Posted lifecycle, just priced differently — are
/// rung up individually through the day with no GL impact of their own; <see cref="CloseAsync"/>
/// is the single point where that day's sales become real accounting entries — one consolidated
/// journal entry per cashier per day, mirroring how <see cref="GrnService"/> posts per receipt.
/// Every document is treated as a cash sale (Debit Cash for GrandTotal): SalesSlip's
/// PaidAmount/BalanceAmount fields exist for a future partial-payment flow but nothing
/// sets them today (SalesSlipService.CreateAsync always leaves PaidAmount at 0), so
/// they aren't a usable signal here — see docs/14-BACKEND-SALES-API.md if that changes.
/// A bundle sale's net revenue is always exactly <c>BundlePrice</c> (<c>GrandTotal - TaxTotal</c>
/// by construction, docs/15) regardless of whether the bundle sold at a discount or a premium to
/// its component subtotal, so it folds into Subtotal/DiscountTotal the same way a slip's net
/// (Subtotal - DiscountTotal) does, without a separate "bundle discount" GL line.
/// </summary>
public sealed class SalesDayEndService : ISalesDayEndService
{
private readonly IRepository<SalesDayEnd> _dayEnds;
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Item> _items;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glCashAccountCode;
private readonly string _glSalesRevenueAccountCode;
private readonly string _glSalesDiscountAccountCode;
private readonly string _glTaxPayableAccountCode;
private readonly string _glCogsAccountCode;
private readonly string _glInventoryAccountCode;
public SalesDayEndService(
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_dayEnds = dayEnds;
_slips = slips;
_bundles = bundles;
_ledger = ledger;
_items = items;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glCashAccountCode = configuration["SalesDayEnd:GlCashAccountCode"] ?? string.Empty;
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
}
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
{
var existing = await _dayEnds.Query().AsNoTracking()
.FirstOrDefaultAsync(x => x.CashierUserId == cashierUserId && x.BusinessDate == businessDate, ct);
if (existing is not null)
{
var closedSlipNumbers = await _slips.Query().AsNoTracking()
.Where(x => x.DayEndId == existing.SalesDayEndId)
.OrderBy(x => x.SlipNo)
.Select(x => x.SlipNo)
.ToListAsync(ct);
var closedBundleNumbers = await _bundles.Query().AsNoTracking()
.Where(x => x.DayEndId == existing.SalesDayEndId)
.OrderBy(x => x.BundleNo)
.Select(x => x.BundleNo)
.ToListAsync(ct);
return new SalesDayEndPreviewDto(
cashierUserId, businessDate, true,
existing.SlipCount, existing.BundleCount, existing.Subtotal, existing.DiscountTotal, existing.TaxTotal, existing.GrandTotal,
closedSlipNumbers, closedBundleNumbers,
Array.Empty<DraftSlipBlockingCloseDto>(), Array.Empty<DraftBundleBlockingCloseDto>());
}
var openSlips = await LoadOpenSlipsAsync(cashierUserId, businessDate, ct);
var postedSlips = openSlips.Where(x => x.Status == SalesSlipStatus.Posted).ToList();
var draftSlips = openSlips.Where(x => x.Status == SalesSlipStatus.Draft).ToList();
var openBundles = await LoadOpenBundleSalesAsync(cashierUserId, businessDate, ct);
var postedBundles = openBundles.Where(x => x.Status == BundleSaleStatus.Posted).ToList();
var draftBundles = openBundles.Where(x => x.Status == BundleSaleStatus.Draft).ToList();
var subtotal = postedSlips.Sum(x => x.Subtotal) + postedBundles.Sum(x => x.BundlePrice);
var discountTotal = postedSlips.Sum(x => x.DiscountTotal);
var taxTotal = postedSlips.Sum(x => x.TaxTotal) + postedBundles.Sum(x => x.TaxTotal);
var grandTotal = postedSlips.Sum(x => x.GrandTotal) + postedBundles.Sum(x => x.GrandTotal);
return new SalesDayEndPreviewDto(
cashierUserId, businessDate, false,
postedSlips.Count, postedBundles.Count, subtotal, discountTotal, taxTotal, grandTotal,
postedSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo).ToList(),
postedBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo).ToList(),
draftSlips.OrderBy(x => x.SlipNo).Select(x => new DraftSlipBlockingCloseDto(x.SalesSlipId, x.SlipNo, x.GrandTotal)).ToList(),
draftBundles.OrderBy(x => x.BundleNo).Select(x => new DraftBundleBlockingCloseDto(x.BundleSaleId, x.BundleNo, x.GrandTotal)).ToList());
}
public async Task<SalesDayEndDto> CloseAsync(CreateSalesDayEndRequest request, CancellationToken ct = default)
{
var businessDate = request.BusinessDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
// Idempotent replay (same pattern as GrnService.ConfirmAsync): closing an
// already-closed date returns the existing record instead of erroring.
var existing = await _dayEnds.Query()
.FirstOrDefaultAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == businessDate, ct);
if (existing is not null)
return await BuildDtoAsync(existing, ct);
var openSlips = await LoadOpenSlipsAsync(request.CashierUserId, businessDate, ct);
var draftSlips = openSlips.Where(x => x.Status == SalesSlipStatus.Draft).ToList();
var openBundles = await LoadOpenBundleSalesAsync(request.CashierUserId, businessDate, ct);
var draftBundles = openBundles.Where(x => x.Status == BundleSaleStatus.Draft).ToList();
if (draftSlips.Count > 0 || draftBundles.Count > 0)
{
var parts = new List<string>();
if (draftSlips.Count > 0)
parts.Add($"{draftSlips.Count} sales slip(s): " + string.Join(", ", draftSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo)));
if (draftBundles.Count > 0)
parts.Add($"{draftBundles.Count} bundle sale(s): " + string.Join(", ", draftBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo)));
throw new ConflictException(
"Still Draft for this cashier/date — post or cancel them before closing the day: " + string.Join("; ", parts));
}
var postedSlips = openSlips.Where(x => x.Status == SalesSlipStatus.Posted).ToList();
var settledSlips = openSlips.Where(x => x.Status != SalesSlipStatus.Draft).ToList(); // Posted + Cancelled — everything gets frozen against this close
var postedBundles = openBundles.Where(x => x.Status == BundleSaleStatus.Posted).ToList();
var settledBundles = openBundles.Where(x => x.Status != BundleSaleStatus.Draft).ToList();
var subtotal = postedSlips.Sum(x => x.Subtotal) + postedBundles.Sum(x => x.BundlePrice);
var discountTotal = postedSlips.Sum(x => x.DiscountTotal);
var taxTotal = postedSlips.Sum(x => x.TaxTotal) + postedBundles.Sum(x => x.TaxTotal);
var grandTotal = postedSlips.Sum(x => x.GrandTotal) + postedBundles.Sum(x => x.GrandTotal);
var slipIds = postedSlips.Select(x => x.SalesSlipId).ToList();
var bundleIds = postedBundles.Select(x => x.BundleSaleId).ToList();
var slipCogs = slipIds.Count == 0
? 0m
: await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.SalesSlip && slipIds.Contains(l.SourceDocId) && l.Direction == Direction.Out)
.SumAsync(l => l.Value, ct);
var bundleCogs = bundleIds.Count == 0
? 0m
: await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.BundleSale && bundleIds.Contains(l.SourceDocId) && l.Direction == Direction.Out)
.SumAsync(l => l.Value, ct);
var cogs = slipCogs + bundleCogs;
var dayEnd = await _uow.ExecuteInTransactionAsync(async token =>
{
var now = DateTime.UtcNow;
var docNo = await _numbers.NextAsync(DocumentTypes.SalesDayEnd, token);
var entity = new SalesDayEnd
{
DocNo = docNo,
CashierUserId = request.CashierUserId,
BusinessDate = businessDate,
SlipCount = postedSlips.Count,
BundleCount = postedBundles.Count,
Subtotal = subtotal,
DiscountTotal = discountTotal,
TaxTotal = taxTotal,
GrandTotal = grandTotal,
CostOfGoodsSold = cogs,
ClosedBy = _currentUser.AuditUserId,
ClosedAt = now
};
if (grandTotal > 0 || cogs > 0)
{
var period = await _gl.GetPeriodByDateAsync(businessDate, token);
var glLines = new List<GlJournalEntryLineRequest>();
if (grandTotal > 0)
{
glLines.Add(new GlJournalEntryLineRequest(_glCashAccountCode, grandTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
if (discountTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, discountTotal, 0m, "Sales discount"));
glLines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, subtotal, "Sales revenue"));
if (taxTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, taxTotal, "Output tax payable"));
}
if (cogs > 0)
{
glLines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
}
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = businessDate,
SourceModule = "SALES-DAYEND",
Reference = docNo,
Narration = $"Cashier day-end {docNo} — {businessDate:yyyy-MM-dd}",
Lines = glLines
}, token);
entity.GlJournalNo = glResult.JournalNo;
entity.GlPostedAt = now;
}
// Relationship fixup, not a direct FK assignment: `entity` has no real id yet
// (assigned by SaveChangesAsync inside ExecuteInTransactionAsync) — EF resolves
// the FK on these already-tracked rows from the navigation once it does, so the
// day-end row and every slip's/bundle's DayEndId commit together in one transaction.
foreach (var slip in settledSlips) slip.DayEnd = entity;
foreach (var bundle in settledBundles) bundle.DayEnd = entity;
await _dayEnds.AddAsync(entity, token);
return entity;
}, ct);
return await BuildDtoAsync(dayEnd, ct);
}
public async Task<PagedResponse<SalesDayEndSummaryDto>> ListAsync(PageQuery query, int? cashierUserId, CancellationToken ct = default)
{
var q = _dayEnds.Query().AsNoTracking();
if (cashierUserId is not null) q = q.Where(x => x.CashierUserId == cashierUserId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesDayEndId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
var items = rows.Select(x => new SalesDayEndSummaryDto(
x.SalesDayEndId, x.DocNo, x.CashierUserId, x.BusinessDate, x.SlipCount, x.BundleCount, x.GrandTotal, x.GlJournalNo, x.ClosedAt)).ToList();
return PagedResponse<SalesDayEndSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<SalesDayEndDto?> GetAsync(int salesDayEndId, CancellationToken ct = default)
{
var entity = await _dayEnds.Query().AsNoTracking().FirstOrDefaultAsync(x => x.SalesDayEndId == salesDayEndId, ct);
return entity is null ? null : await BuildDtoAsync(entity, ct);
}
private async Task<List<SalesSlip>> LoadOpenSlipsAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct)
{
var (dayStart, dayEnd) = DayRangeUtc(businessDate);
return await _slips.Query()
.Where(x => x.CashierUserId == cashierUserId && x.DayEndId == null && x.SlipDate >= dayStart && x.SlipDate < dayEnd)
.ToListAsync(ct);
}
private async Task<List<BundleSale>> LoadOpenBundleSalesAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct)
{
var (dayStart, dayEnd) = DayRangeUtc(businessDate);
return await _bundles.Query()
.Where(x => x.CashierUserId == cashierUserId && x.DayEndId == null && x.BundleDate >= dayStart && x.BundleDate < dayEnd)
.ToListAsync(ct);
}
// SlipDate/BundleDate are stored as DateTime.UtcNow (Kind=Utc) throughout — Npgsql requires
// a matching Kind=Utc here too, or comparisons against the `timestamptz` column throw.
private static (DateTime Start, DateTime End) DayRangeUtc(DateOnly businessDate)
{
var start = DateTime.SpecifyKind(businessDate.ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc);
return (start, start.AddDays(1));
}
private async Task<SalesDayEndDto> BuildDtoAsync(SalesDayEnd entity, CancellationToken ct)
{
var postedSlips = await _slips.Query().AsNoTracking().Include(x => x.Lines)
.Where(x => x.DayEndId == entity.SalesDayEndId && x.Status == SalesSlipStatus.Posted)
.ToListAsync(ct);
var postedBundles = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
.Where(x => x.DayEndId == entity.SalesDayEndId && x.Status == BundleSaleStatus.Posted)
.ToListAsync(ct);
var slipNumbers = postedSlips.OrderBy(x => x.SlipNo).Select(x => x.SlipNo).ToList();
var bundleNumbers = postedBundles.OrderBy(x => x.BundleNo).Select(x => x.BundleNo).ToList();
var slipQtyRevenue = postedSlips.SelectMany(x => x.Lines)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(l => l.Qty + l.FreeQty), Revenue = g.Sum(l => l.LineTotal) });
var bundleQtyRevenue = postedBundles.SelectMany(x => x.Lines)
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(l => l.Qty), Revenue = g.Sum(l => l.LineTotal) });
var grouped = slipQtyRevenue.Concat(bundleQtyRevenue)
.GroupBy(x => x.ItemId)
.Select(g => new { ItemId = g.Key, Qty = g.Sum(x => x.Qty), Revenue = g.Sum(x => x.Revenue) })
.ToList();
var itemIds = grouped.Select(g => g.ItemId).ToList();
var itemInfo = await _items.Query().AsNoTracking()
.Where(i => itemIds.Contains(i.ItemId))
.Select(i => new { i.ItemId, i.Sku, i.Name })
.ToListAsync(ct);
var breakdown = grouped
.Select(g =>
{
var info = itemInfo.FirstOrDefault(i => i.ItemId == g.ItemId);
return new SalesDayEndItemLineDto(g.ItemId, info?.Sku ?? $"SKU-{g.ItemId}", info?.Name ?? "—", g.Qty, g.Revenue);
})
.OrderByDescending(x => x.Revenue)
.ToList();
return new SalesDayEndDto(
entity.SalesDayEndId, entity.DocNo, entity.CashierUserId, entity.BusinessDate,
entity.SlipCount, entity.BundleCount, entity.Subtotal, entity.DiscountTotal, entity.TaxTotal, entity.GrandTotal,
entity.CostOfGoodsSold, entity.GlJournalNo, entity.GlPostedAt, entity.ClosedBy, entity.ClosedAt,
slipNumbers, bundleNumbers, breakdown);
}
}
@@ -1,122 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales;
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>
/// Customer payments against a Posted sales invoice. Multiple installments are allowed
/// until <see cref="SalesInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit the selected bank-or-cash account / Credit Accounts
/// Receivable) before being recorded, using the same call-GL-before-commit pattern as
/// <see cref="GrnPaymentService"/> — the mirror image of it (that one credits a payable
/// clearing account when vendor-paid; this one credits the receivable asset when
/// customer-paid) — so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
{
private readonly IRepository<SalesInvoice> _invoices;
private readonly IRepository<SalesInvoicePayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsReceivableCode;
public SalesInvoicePaymentService(
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
}
public async Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default)
{
var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Posted)
throw new DomainException(ErrorCodes.SalesInvoiceNotPayable, $"Sales invoice {salesInvoiceId} must be posted before it can be paid.", 409);
if (request.Amount > invoice.BalanceAmount)
throw new DomainException(ErrorCodes.SalesInvoicePaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {invoice.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.SalesInvoiceBankAccountNotFound, $"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 GrnPaymentService.PayAsync: 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 = "SALES_INVOICE_PAYMENT",
Reference = invoice.InvoiceNo,
Narration = $"Payment against invoice {invoice.InvoiceNo}",
Lines =
[
new GlJournalEntryLineRequest(account.GlAccountCode, request.Amount, 0m, $"Payment against invoice {invoice.InvoiceNo}"),
new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}")
]
}, token);
var entity = new SalesInvoicePayment
{
SalesInvoiceId = invoice.SalesInvoiceId,
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);
invoice.PaidAmount += request.Amount;
invoice.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<SalesInvoicePaymentDto>> ListAsync(int salesInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.SalesInvoiceId == salesInvoiceId, ct))
throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.SalesInvoiceId == salesInvoiceId)
.OrderByDescending(p => p.SalesInvoicePaymentId)
.Select(p => new SalesInvoicePaymentDto(
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static SalesInvoicePaymentDto Map(SalesInvoicePayment p) => new(
p.SalesInvoicePaymentId, p.SalesInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
+17 -25
View File
@@ -80,32 +80,24 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
var customerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
var lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
var invoice = new SalesInvoice
{
var entity = new SalesInvoice
{
InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, token),
InvoiceDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
InvoiceType = request.InvoiceType,
Status = SalesInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
CustomerSnapshotName = customerSnapshotName,
CustomerSnapshotTaxNo = customerSnapshotTaxNo,
Lines = lines
};
Recalculate(entity);
await _invoices.AddAsync(entity, token);
return entity;
}, ct);
InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct),
InvoiceDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
InvoiceType = request.InvoiceType,
Status = SalesInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow
};
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
invoice.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
Recalculate(invoice);
await _invoices.AddAsync(invoice, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
}
@@ -198,5 +190,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt);
}
@@ -33,7 +33,6 @@ public sealed class SalesMappingService : ISalesMappingService
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
invoice.GlJournalNo, invoice.GlPostedAt,
MapInvoiceTotals(invoice),
invoice.Lines.Select(l => new SalesInvoiceLineDto(
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
@@ -5,7 +5,6 @@ using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
@@ -23,13 +22,6 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly ISalesDomainService _sales;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glAccountsReceivableCode;
private readonly string _glSalesRevenueAccountCode;
private readonly string _glSalesDiscountAccountCode;
private readonly string _glTaxPayableAccountCode;
private readonly string _glCogsAccountCode;
private readonly string _glInventoryAccountCode;
public SalesPostingService(
IRepository<SalesInvoice> invoices,
@@ -39,9 +31,7 @@ public sealed class SalesPostingService : ISalesPostingService
IFifoCostingService fifo,
ISalesDomainService sales,
ICurrentUser currentUser,
IUnitOfWork uow,
IGeneralLedgerService gl,
IConfiguration configuration)
IUnitOfWork uow)
{
_invoices = invoices;
_slips = slips;
@@ -51,15 +41,6 @@ public sealed class SalesPostingService : ISalesPostingService
_sales = sales;
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
// Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice
// just recognizes them immediately on Post instead of waiting for the day's close.
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
}
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -162,51 +143,7 @@ public sealed class SalesPostingService : ISalesPostingService
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice,
getDocId: x => x.SalesInvoiceId,
ct: ct,
afterConsumption: PostInvoiceRevenueAsync);
/// <summary>
/// Revenue recognition for an invoice, run inside the same transaction as its stock
/// consumption (so a GL rejection rolls back the posting too, not just leaves it
/// half-done): Debit Accounts Receivable for the full NetPayable, Credit Sales Revenue
/// (net of discount) and Output Tax, and Debit COGS / Credit Inventory for whatever the
/// consumption loop above just cost. Unlike SalesSlip/BundleSale, an invoice is not a
/// cashier document and never goes through Sales Day End, so this is its only GL entry.
/// </summary>
private async Task PostInvoiceRevenueAsync(SalesInvoice invoice, decimal cogs, CancellationToken ct)
{
if (invoice.NetPayable <= 0 && cogs <= 0) return;
var now = DateTime.UtcNow;
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
var lines = new List<GlJournalEntryLineRequest>();
if (invoice.NetPayable > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
if (invoice.DiscountTotal > 0)
lines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, invoice.DiscountTotal, 0m, "Sales discount"));
lines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, invoice.Subtotal, "Sales revenue"));
if (invoice.TaxTotal > 0)
lines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, invoice.TaxTotal, "Output tax payable"));
}
if (cogs > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
}
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "SALES_INVOICE",
Reference = invoice.InvoiceNo,
Narration = $"Sales invoice {invoice.InvoiceNo} posted",
Lines = lines
}, ct);
invoice.GlJournalNo = result.JournalNo;
invoice.GlPostedAt = now;
}
ct: ct);
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
=> PostAsync(
@@ -244,11 +181,7 @@ public sealed class SalesPostingService : ISalesPostingService
Action<T> setUpdated,
string sourceDocType,
Func<T, int> getDocId,
CancellationToken ct,
/// <summary>Run inside the same transaction, after consumption and status flip, with the
/// total COGS this call just consumed — the invoice-only revenue-recognition hook.
/// Null for slip/bundle posting, which stays GL-silent here (Sales Day End handles them).</summary>
Func<T, decimal, CancellationToken, Task>? afterConsumption = null)
CancellationToken ct)
where T : class
{
var doc = await load() ?? throw new NotFoundException(notFoundMessage);
@@ -259,7 +192,6 @@ public sealed class SalesPostingService : ISalesPostingService
await _uow.ExecuteInTransactionAsync(async token =>
{
var totalCogs = 0m;
foreach (var line in getLines(doc))
{
if (line.Qty <= 0) continue;
@@ -270,15 +202,12 @@ public sealed class SalesPostingService : ISalesPostingService
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
totalCogs += cost * line.Qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
}
setPosted(doc);
setUpdated(doc);
if (afterConsumption is not null) await afterConsumption(doc, totalCogs, token);
}, ct);
}
+3 -37
View File
@@ -6,7 +6,6 @@ using ERPCore.Dtos.Sales;
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;
@@ -18,10 +17,7 @@ namespace ERPCore.Services;
/// 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. Also posts a real GL journal entry reversing Inventory/COGS for the
/// stock movement's value (see <see cref="SalesReturn.GlJournalNo"/> for why revenue/tax
/// aren't part of it) — reuses the Sales Day End accounts since that's the module
/// whose COGS this reverses.
/// reversed.
/// </summary>
public sealed class SalesReturnService : ISalesReturnService
{
@@ -37,16 +33,12 @@ public sealed class SalesReturnService : ISalesReturnService
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glCogsAccountCode;
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,
IGeneralLedgerService gl, IConfiguration configuration)
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_customers = customers;
@@ -60,9 +52,6 @@ public sealed class SalesReturnService : ISalesReturnService
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
}
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
@@ -83,7 +72,7 @@ public sealed class SalesReturnService : ISalesReturnService
.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), r.GlJournalNo))
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);
@@ -175,28 +164,6 @@ public sealed class SalesReturnService : ISalesReturnService
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0)
{
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "SALES-RETURN",
Reference = docNo,
Narration = $"Sales return {docNo} — stock restocked",
Lines = new List<GlJournalEntryLineRequest>
{
new(_glInventoryAccountCode, totalValue, 0m, $"Sales return {docNo}"),
new(_glCogsAccountCode, 0m, totalValue, $"Sales return {docNo} — COGS reversal")
}
}, token);
ret.GlJournalNo = glResult.JournalNo;
ret.GlPostedAt = now;
}
return (ret, refs);
}, ct);
@@ -227,7 +194,6 @@ public sealed class SalesReturnService : ISalesReturnService
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.GlJournalNo, r.GlPostedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
ledgerRefs);
+15 -32
View File
@@ -17,7 +17,6 @@ namespace ERPCore.Services;
public sealed class SalesSlipService : ISalesSlipService
{
private readonly IRepository<SalesSlip> _slips;
private readonly IRepository<SalesDayEnd> _dayEnds;
private readonly IRepository<Customer> _customers;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
@@ -32,13 +31,12 @@ public sealed class SalesSlipService : ISalesSlipService
private readonly IUnitOfWork _uow;
public SalesSlipService(
IRepository<SalesSlip> slips, IRepository<SalesDayEnd> dayEnds, IRepository<Customer> customers, IRepository<Item> items,
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
{
_slips = slips;
_dayEnds = dayEnds;
_customers = customers;
_items = items;
_uoms = uoms;
@@ -109,37 +107,22 @@ public sealed class SalesSlipService : ISalesSlipService
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
// A cashier can't ring up more sales for a business date they've already closed
// (SalesDayEndService.CloseAsync) — otherwise the new slip would sit outside every
// day-end's totals and GL posting forever (docs/14 Sales Day End).
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var alreadyClosed = await _dayEnds.Query().AsNoTracking()
.AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct);
if (alreadyClosed)
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
var lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
var slip = await _uow.ExecuteInTransactionAsync(async token =>
var slip = new SalesSlip
{
var entity = new SalesSlip
{
SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, token),
SlipDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
CashierUserId = request.CashierUserId,
CustomerSnapshotName = customerSnapshotName,
Status = SalesSlipStatus.Draft,
CreatedAt = DateTime.UtcNow,
Lines = lines
};
Recalculate(entity);
await _slips.AddAsync(entity, token);
return entity;
}, ct);
SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, ct),
SlipDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
CashierUserId = request.CashierUserId,
Status = SalesSlipStatus.Draft,
CreatedAt = DateTime.UtcNow
};
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
Recalculate(slip);
await _slips.AddAsync(slip, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
}
@@ -17,14 +17,6 @@ namespace ERPCore.Services;
/// layers (row-locked; negative-stock blocked) and records the value-weighted cost
/// on the line; receive recreates the destination layer at that cost
/// (cost-preserving — no revaluation). Both run in a single UoW transaction.
///
/// Deliberately posts no GL journal entry: <see cref="Warehouse"/> carries no GL
/// account of its own (unlike a GL "location"/cost-center dimension), so every
/// warehouse's stock sits in the same Inventory account — a transfer would debit and
/// credit that identical account for the identical amount, a no-op journal entry that
/// exists only to say nothing. The per-warehouse movement is still fully recorded in
/// <see cref="StockLedger"/>/<see cref="StockLayer"/>, which is what On-Hand-by-warehouse
/// reporting actually reads from.
/// </summary>
public sealed class TransferService : ITransferService
{
@@ -70,16 +70,4 @@ 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";
// Sales invoice payments
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";
public const string SalesInvoicePaymentExceedsBalance = "SALES_INVOICE_PAYMENT_EXCEEDS_BALANCE";
public const string SalesInvoiceBankAccountNotFound = "SALES_INVOICE_BANK_ACCOUNT_NOT_FOUND";
}
-21
View File
@@ -26,26 +26,5 @@
"BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
},
"Grn": {
"GlInventoryAccountCode": "1100",
"GlVatRecoverableAccountCode": "1200",
"GlClearingAccountCode": "2000"
},
"SalesDayEnd": {
"GlCashAccountCode": "1000-01",
"GlSalesRevenueAccountCode": "4000",
"GlSalesDiscountAccountCode": "4100",
"GlTaxPayableAccountCode": "2100",
"GlCogsAccountCode": "5000",
"GlInventoryAccountCode": "1100"
},
"Adjustment": {
"GlInventoryAccountCode": "1100",
"GlGainAccountCode": "4200",
"GlLossAccountCode": "5100"
},
"SalesInvoice": {
"GlAccountsReceivableCode": "1300"
},
"AllowedHosts": "*"
}
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils"
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { FieldError } from "@/components/ui/field"
@@ -200,6 +199,7 @@ export default function PurchaseOrderDetailPage() {
async function handleDelete() {
if (!po) return
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
setSaveError(null)
setDeleting(true)
try {
@@ -280,21 +280,10 @@ export default function PurchaseOrderDetailPage() {
<Check className="size-5" />
{submitting ? "Approving�" : "Approve"}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />}
>
<Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete draft ${po.docNo}?`}
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={handleDelete}
/>
</AlertDialog>
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"}
</Button>
</>
)}
{cancellable && !showCancelForm && (
@@ -11,7 +11,6 @@ import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
@@ -65,6 +64,7 @@ export default function PurchaseOrdersListPage() {
}
async function handleDelete(po: PurchaseOrderSummary) {
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
setDeletingId(po.poId)
try {
await purchaseOrdersApi.remove(po.poId)
@@ -78,6 +78,7 @@ export default function PurchaseOrdersListPage() {
}
async function handleApprove(po: PurchaseOrderSummary) {
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
setApprovingId(po.poId)
try {
const updated = await purchaseOrdersApi.submit(po.poId)
@@ -202,54 +203,30 @@ export default function PurchaseOrdersListPage() {
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Approve draft"
title="Approve draft"
disabled={approvingId === po.poId || deletingId === po.poId}
className="text-success hover:bg-success/10 hover:text-success"
/>
}
>
<Check className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="success"
title={`Approve ${po.docNo}?`}
description="It will be locked for editing once approved."
confirmLabel="Approve"
onConfirm={() => handleApprove(po)}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || approvingId === po.poId}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete draft ${po.docNo}?`}
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={() => handleDelete(po)}
/>
</AlertDialog>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Approve draft"
title="Approve draft"
disabled={approvingId === po.poId || deletingId === po.poId}
onClick={() => handleApprove(po)}
className="text-success hover:bg-success/10 hover:text-success"
>
<Check className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || approvingId === po.poId}
onClick={() => handleDelete(po)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</>
)}
</div>
@@ -91,7 +91,6 @@ export default function PurchaseReturnsListPage() {
<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-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
@@ -107,7 +106,6 @@ export default function PurchaseReturnsListPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -14,12 +14,14 @@ import {
RunStage,
RunStageInput,
RunStageOutput,
StageQtyUnit,
StageQuantityLine,
TransferLine,
} from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
import { ReasonCode } from "@/types/stock"
import { OnHand, ReasonCode } from "@/types/stock"
import { stockApi } from "@/lib/api/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
}
/**
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
* to ask for a reason up front. The server always recomputes it authoritatively.
*/
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
if (unit === "Pack" || itemId === null) return qty
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
if (!contentBaseQty) return qty
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
// stored at that rounded value, so comparing against the raw division here would flag an
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
// decrease and demand a reason code nothing actually changed.
return Math.round((qty / contentBaseQty) * 10000) / 10000
}
function StatusPill({ status }: { status: StageStatus }) {
const color = STAGE_STATUS_COLOR[status]
return (
@@ -102,9 +120,13 @@ export function StageDrawer({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Editable planned quantities (Ready only) keyed by run input/output id.
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
// Reason code for a running Stock input whose planned qty is being lowered below what it
// has already consumed — required by the server the same way a leftover return is.
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
// Complete-form state.
const [produced, setProduced] = useState<Record<number, string>>({})
@@ -135,6 +157,7 @@ export function StageDrawer({
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
setInputReasonCode({})
setError(null)
idempotencyKey.current = crypto.randomUUID()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -212,7 +235,11 @@ export function StageDrawer({
function saveQuantities() {
const inputs: StageQuantityLine[] = stage!.inputs
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
.map((i) => ({
id: i.runInputId,
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
}))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
const outputs: StageQuantityLine[] = stage!.outputs
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
@@ -284,6 +311,21 @@ export function StageDrawer({
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
// lowering it returns the excess and — same rule a leftover return already enforces
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
const isInputDecrease = (input: RunStageInput) => {
const plannedBase = toPacksClient(
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
)
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
}
const missingInputReasons =
stage.status === "InProgress"
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
: []
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
const missingScrapReasons = stage.outputs
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
@@ -339,6 +381,7 @@ export function StageDrawer({
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
/>
))}
</section>
@@ -424,6 +467,39 @@ export function StageDrawer({
</section>
)}
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
<p className="text-sm text-muted-foreground">
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
reason for any decrease.
</p>
{runningStockInputs.map((input) => (
<RunningStockInputRow
key={input.runInputId}
input={input}
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
decreasing={isInputDecrease(input)}
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
scrapReasons={scrapReasons}
/>
))}
{missingInputReasons.length > 0 && (
<p className="text-sm text-destructive">
Pick a reason for lowering: {missingInputReasons.join(", ")}.
</p>
)}
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
Save quantities
</Button>
</section>
)}
{stage.status === "InProgress" && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Record output</h3>
@@ -662,6 +738,56 @@ export function StageDrawer({
}
/** Split out only because the input card has three mutually exclusive quantity presentations. */
/**
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
*/
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
const [onHand, setOnHand] = useState<OnHand | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || itemId === null) {
setOnHand(null)
setError(null)
return
}
setLoading(true)
setError(null)
stockApi
.onHand(itemId, warehouseId)
.then(setOnHand)
.catch((err) => {
setOnHand(null)
setError(errorMessage(err))
})
.finally(() => setLoading(false))
}, [enabled, itemId, warehouseId])
return { onHand, loading, error }
}
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
* RunningStockInputRow both show under an editable Planned qty. */
function AvailableStockLine({
loading, error, onHand,
}: {
loading: boolean
error: string | null
onHand: OnHand | null
}) {
return (
<p className="text-right text-xs text-muted-foreground">
{loading
? "Checking available stock…"
: error
? "Available stock unknown"
: onHand
? `Available: ${fmt(onHand.available)}`
: null}
</p>
)
}
function InputCard({
input,
editable,
@@ -669,6 +795,7 @@ function InputCard({
onValueChange,
itemName,
unitLabel,
warehouseId,
}: {
input: RunStageInput
editable: boolean
@@ -676,10 +803,16 @@ function InputCard({
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
// Available stock is only meaningful while the planned qty is still editable (Ready) and
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
const { onHand, loading: onHandLoading, error: onHandError } =
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
return (
<div className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
@@ -691,17 +824,22 @@ function InputCard({
<div className="mt-2 flex flex-col gap-1">
{editable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
{!isUpstream && input.itemId !== null && (
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
)}
</>
) : (
<Row label="Planned">{fmt(input.plannedQty)}</Row>
)}
@@ -735,6 +873,69 @@ function InputCard({
)
}
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
function RunningStockInputRow({
input,
value,
onValueChange,
itemName,
unitLabel,
warehouseId,
decreasing,
reasonCodeId,
onReasonChange,
scrapReasons,
}: {
input: RunStageInput
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
decreasing: boolean
reasonCodeId: number | null
onReasonChange: (id: number) => void
scrapReasons: ReasonCode[]
}) {
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{itemName(input.itemId)}</p>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
{decreasing && (
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Reason for lowering (required)" />
</SelectTrigger>
<SelectContent>
{scrapReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
{r.code} — {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
function CustomFields({
stage,
@@ -27,6 +27,10 @@ function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
function fmtStock(available: number | undefined) {
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
@@ -128,6 +132,7 @@ export function StageEditorPanel({
upstreamOptions,
items,
uoms,
stockByItem,
readOnly,
onChange,
onDelete,
@@ -138,6 +143,8 @@ export function StageEditorPanel({
upstreamOptions: UpstreamOutputOption[]
items: ItemListItem[]
uoms: Uom[]
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
stockByItem: Map<number, number>
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
@@ -239,18 +246,19 @@ export function StageEditorPanel({
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
<Select<string>
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
onValueChange={(v) => v && onChange({ roleLabel: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick a role" />
</SelectTrigger>
<SelectContent>
{ROLE_SUGGESTIONS.map((r) => (
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
@@ -312,6 +320,9 @@ export function StageEditorPanel({
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
<span className="text-muted-foreground">
· Stock: {fmtStock(stockByItem.get(i.itemId))}
</span>
</SelectItem>
))}
</SelectContent>
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
}, [isNew, templateId, load])
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes]) => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list({ pageSize: 200 }),
stockApi.onHandList({ pageSize: 200 }),
])
.then(([itemRes, uomRes, onHandRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
// Templates aren't tied to a warehouse, so the picker shows availability summed across
// every warehouse the item holds stock in.
const byItem = new Map<number, number>()
for (const row of onHandRes.items) {
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
}
setStockByItem(byItem)
})
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
}, [])
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
stockByItem={stockByItem}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { BookOpen, CheckCircle2, CircleDollarSign, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -11,9 +11,8 @@ 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, GrnPayment } from "@/types/grn"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
@@ -21,8 +20,7 @@ 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 { GrnPaymentStatusBadge, GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
import { GrnPaymentDialog } from "@/components/receiving/GrnPaymentDialog"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
export default function GrnDetailPage() {
const params = useParams<{ id: string }>()
@@ -38,9 +36,6 @@ 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())
@@ -60,18 +55,6 @@ 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)
}
@@ -87,10 +70,8 @@ export default function GrnDetailPage() {
try {
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
setConfirmResult(result)
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}.`)
setGrn((prev) => (prev ? { ...prev, status: result.status } : prev))
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock.`)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not confirm GRN", errorMessage(err))
@@ -140,7 +121,6 @@ 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}
@@ -148,36 +128,14 @@ export default function GrnDetailPage() {
</div>
</div>
<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>
{grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" />
{confirming ? "Confirming…" : "Confirm GRN"}
</Button>
)}
</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>
)}
@@ -192,7 +150,6 @@ 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>
)}
@@ -301,7 +258,7 @@ export default function GrnDetailPage() {
</Table>
</div>
<div className="flex flex-wrap justify-end gap-8 border-t border-border pt-4 text-base">
<div className="flex 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>
@@ -314,49 +271,7 @@ 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>
)
}
@@ -134,28 +134,6 @@ export default function NewGrnPage() {
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [lineTab, setLineTab] = useState<LineTab>("lines")
// Whole-GRN discount/VAT: entering either one here applies that same rate to every
// line's discountPct/vatPct (clearing whatever was entered per-item), so it flows
// through the normal per-line NetUnitCost/FIFO-layer costing instead of being a
// separate header-only adjustment. Independent per field — setting one doesn't
// touch the other. Cleared back to blank, the per-line fields become editable again.
const [headerDiscountPct, setHeaderDiscountPct] = useState("")
const [headerVatPct, setHeaderVatPct] = useState("")
function applyHeaderDiscountPct(value: string) {
setHeaderDiscountPct(value)
const trimmed = value.trim()
if (trimmed === "") return
setLines((prev) => prev.map((l) => ({ ...l, discountPct: trimmed })))
}
function applyHeaderVatPct(value: string) {
setHeaderVatPct(value)
const trimmed = value.trim()
if (trimmed === "") return
setLines((prev) => prev.map((l) => ({ ...l, vatPct: trimmed })))
}
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
@@ -193,8 +171,6 @@ export default function NewGrnPage() {
setPoId(null)
setVendorId(null)
setLines([emptyLine()])
setHeaderDiscountPct("")
setHeaderVatPct("")
setHeaderError(null)
setLineErrors({})
}
@@ -227,8 +203,8 @@ export default function NewGrnPage() {
qty: String(l.qty - l.qtyReceived),
unitCost: String(l.unitPrice),
poUnitPrice: l.unitPrice,
discountPct: headerDiscountPct.trim() || "0",
vatPct: headerVatPct.trim() || "0",
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
warrantyNumbers: [],
})
@@ -471,38 +447,6 @@ export default function NewGrnPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-input p-4 sm:flex-row sm:items-end sm:gap-4">
<div className="flex flex-1 flex-col gap-2">
<Label className="text-base">Whole-GRN discount %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
value={headerDiscountPct}
onChange={(e) => applyHeaderDiscountPct(e.target.value)}
placeholder="Applies to every line"
className="h-11 text-base"
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label className="text-base">Whole-GRN VAT %</Label>
<Input
type="number"
min="0"
max="100"
step="any"
value={headerVatPct}
onChange={(e) => applyHeaderVatPct(e.target.value)}
placeholder="Applies to every line"
className="h-11 text-base"
/>
</div>
<p className="text-sm text-muted-foreground sm:max-w-64">
Setting either one applies that rate to every line (replacing any per-item value) and locks the per-item field. Clear it to edit lines individually again.
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -516,20 +460,7 @@ export default function NewGrnPage() {
<div className="flex flex-wrap items-center gap-2">
{/* Off-PO items are allowed on a PO-based GRN — the server treats a line with
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
<Button
type="button"
variant="outline"
onClick={() =>
setLines((prev) => [
...prev,
{
...emptyLine(),
discountPct: headerDiscountPct.trim() || "0",
vatPct: headerVatPct.trim() || "0",
},
])
}
>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
@@ -695,8 +626,6 @@ export default function NewGrnPage() {
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
disabled={headerDiscountPct.trim() !== ""}
title={headerDiscountPct.trim() !== "" ? "Driven by the whole-GRN discount % above" : undefined}
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
@@ -710,8 +639,6 @@ export default function NewGrnPage() {
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
disabled={headerVatPct.trim() !== ""}
title={headerVatPct.trim() !== "" ? "Driven by the whole-GRN VAT % above" : undefined}
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
@@ -6,7 +6,6 @@ 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"
@@ -23,7 +22,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { GrnPaymentStatusBadge, GrnStatusBadge } from "@/components/receiving/status-badges"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
@@ -175,8 +174,6 @@ 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>
@@ -199,16 +196,6 @@ 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">
@@ -1,281 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, CheckCircle2, Lock, ReceiptText } from "lucide-react"
import { salesDayEndApi } from "@/lib/api/sales-day-end"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ManagedUser } from "@/types/users"
import { SalesDayEnd, SalesDayEndPreview } from "@/types/sales"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
export default function SalesDayEndPage() {
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [businessDate, setBusinessDate] = useState(todayIso())
const [preview, setPreview] = useState<SalesDayEndPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [report, setReport] = useState<SalesDayEnd | null>(null)
const [closing, setClosing] = useState(false)
useEffect(() => {
usersApi
.list({ pageSize: 200 })
.then((res) => {
setCashiers(res.items)
setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null)
})
.catch(() => {})
}, [])
function loadPreview() {
if (!cashierUserId) return
setReport(null)
setPreviewError(null)
setPreviewLoading(true)
salesDayEndApi
.preview(cashierUserId, businessDate)
.then(setPreview)
.catch((err) => setPreviewError(errorMessage(err)))
.finally(() => setPreviewLoading(false))
}
useEffect(() => {
loadPreview()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cashierUserId, businessDate])
async function handleClose() {
if (!cashierUserId) return
setClosing(true)
try {
const result = await salesDayEndApi.close({ cashierUserId, businessDate })
setReport(result)
toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`)
loadPreview()
} catch (err) {
toast.error("Could not close the day", errorMessage(err))
} finally {
setClosing(false)
}
}
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
<p className="text-base text-muted-foreground">
Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Cashier</Label>
<Select<number | null> value={cashierUserId} onValueChange={setCashierUserId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
<SelectContent>
{cashiers.map((c) => (
<SelectItem key={c.userId} value={c.userId} className="text-base">
{c.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Business date</Label>
<Input
type="date"
value={businessDate}
max={todayIso()}
onChange={(e) => setBusinessDate(e.target.value)}
className="h-12 text-base"
/>
</div>
</div>
{previewLoading && <Skeleton className="h-40 w-full" />}
{previewError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{previewError}</div>
)}
{!previewLoading && preview && !report && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
{preview.alreadyClosed ? (
<div className="flex items-start gap-3 rounded-lg border border-success/30 bg-success/5 p-4 text-base text-success">
<CheckCircle2 className="size-5 shrink-0" />
<div>
<p className="font-semibold">{cashierName ?? `Cashier ${cashierUserId}`} already closed {businessDate}.</p>
<p className="text-sm text-success/80">{preview.slipCount} slip(s), {preview.bundleCount} bundle(s), {preview.grandTotal.toFixed(2)} total.</p>
</div>
</div>
) : preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 ? (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p className="font-semibold">Still Draft — post or cancel these before closing.</p>
<ul className="list-inside list-disc text-sm">
{preview.draftSlipsBlockingClose.map((s) => (
<li key={`slip-${s.salesSlipId}`}>
<Link href={`/dashboard/sales/slips/${s.salesSlipId}`} className="underline">{s.slipNo}</Link> — {s.grandTotal.toFixed(2)}
</li>
))}
{preview.draftBundleSalesBlockingClose.map((b) => (
<li key={`bundle-${b.bundleSaleId}`}>
<Link href={`/dashboard/sales/bundles/${b.bundleSaleId}`} className="underline">{b.bundleNo}</Link> — {b.grandTotal.toFixed(2)}
</li>
))}
</ul>
</div>
</div>
) : (
<p className="text-base text-muted-foreground">
{preview.slipCount === 0 && preview.bundleCount === 0
? "No sales slips or bundle sales for this cashier on this date."
: `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`}
</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Subtotal" value={preview.subtotal} />
<Stat label="Discount" value={preview.discountTotal} />
<Stat label="Tax" value={preview.taxTotal} />
<Stat label="Grand total" value={preview.grandTotal} emphasize />
</div>
{!preview.alreadyClosed && (
<div className="flex justify-end">
<Button
size="lg"
onClick={handleClose}
disabled={closing || preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 || !cashierUserId}
>
<Lock className="size-5" />
{closing ? "Closing…" : "Close day"}
</Button>
</div>
)}
{preview.alreadyClosed && (
<div className="flex justify-end">
<Button size="lg" variant="outline" onClick={handleClose} disabled={closing}>
<ReceiptText className="size-5" />
{closing ? "Loading…" : "View full report"}
</Button>
</div>
)}
</div>
)}
{report && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div className="flex flex-col gap-1 border-b border-border pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">{report.docNo}</h2>
<p className="text-sm text-muted-foreground">
{cashierName ?? `Cashier ${report.cashierUserId}`} — {report.businessDate} — closed {new Date(report.closedAt).toLocaleString()}
</p>
</div>
{report.glJournalNo && (
<div className="text-sm text-muted-foreground">
GL journal <span className="font-medium text-foreground">{report.glJournalNo}</span> posted {report.glPostedAt ? new Date(report.glPostedAt).toLocaleString() : ""}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-6">
<Stat label="Slips" value={report.slipCount} isCount />
<Stat label="Bundles" value={report.bundleCount} isCount />
<Stat label="Subtotal" value={report.subtotal} />
<Stat label="Discount" value={report.discountTotal} />
<Stat label="Tax" value={report.taxTotal} />
<Stat label="Grand total" value={report.grandTotal} emphasize />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 px-3 text-sm">Item</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Qty</TableHead>
<TableHead className="h-10 px-3 text-right text-sm">Revenue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.itemBreakdown.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="px-3 py-6 text-center text-sm text-muted-foreground">No lines.</TableCell>
</TableRow>
) : (
report.itemBreakdown.map((line) => (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-2.5">{line.sku} — {line.name}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.qty}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-mono tabular-nums">{line.revenue.toFixed(2)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{report.slipNumbers.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Slips included</h3>
<p className="text-sm text-muted-foreground">{report.slipNumbers.join(", ")}</p>
</div>
)}
{report.bundleNumbers.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Bundle sales included</h3>
<p className="text-sm text-muted-foreground">{report.bundleNumbers.join(", ")}</p>
</div>
)}
</div>
)}
</div>
)
}
function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) {
return (
<div className="flex flex-col gap-1 rounded-xl border border-border bg-muted/30 p-3">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("font-mono tabular-nums", emphasize ? "text-lg font-bold text-foreground" : "text-base font-semibold text-foreground")}>
{isCount ? value : value.toFixed(2)}
</span>
</div>
)
}
@@ -1,12 +1,12 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Eraser, Pencil, Plus, Save, Trash2, X } from "lucide-react"
import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Combobox } from "@/components/ui/combobox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { errorMessage } from "@/lib/error-map"
@@ -142,15 +142,6 @@ export default function NewFreeIssuePage() {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
function clearAllLines() {
setLines([blankLine("line-1")])
}
const itemOptions = useMemo(
() => items.map((i) => ({ value: i.itemId, label: `${i.sku} - ${i.name}` })),
[items]
)
function selectItem(key: string, itemId: number) {
updateLine(key, { itemId })
}
@@ -308,12 +299,18 @@ export default function NewFreeIssuePage() {
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectEditingItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectEditingItem(line.key, Number(v))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((candidate) => (
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
{candidate.sku} - {candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
@@ -335,20 +332,9 @@ export default function NewFreeIssuePage() {
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Free issue lines</h2>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={clearAllLines}
disabled={lines.length === 1 && !lines[0].itemId}
>
<Eraser className="size-4" /> Clear all lines
</Button>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
</div>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
@@ -367,12 +353,18 @@ export default function NewFreeIssuePage() {
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-80">
<Combobox
items={itemOptions}
value={line.itemId || null}
onValueChange={(v) => selectItem(line.key, v ?? 0)}
placeholder="Search item…"
/>
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((candidate) => (
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
{candidate.sku} - {candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-44">
{baseUomLabel(items, uoms, line.itemId)}
@@ -16,11 +16,9 @@ import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
type Line = CreateSalesInvoiceLineRequest & { key: string }
@@ -77,8 +75,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
const [payDialogOpen, setPayDialogOpen] = useState(false)
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
@@ -136,26 +132,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
.catch(() => setPostingCheck(null))
}, [invoice, invoiceId])
useEffect(() => {
if (!invoice || invoice.status !== "Posted") {
setPayments([])
return
}
salesApi.listInvoicePayments(invoiceId).then(setPayments).catch(() => setPayments([]))
}, [invoice, invoiceId])
function handlePaid(_updated: SalesInvoice, payment: SalesInvoicePayment) {
setInvoice((prev) =>
prev
? {
...prev,
totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount },
}
: prev
)
setPayments((prev) => [payment, ...prev])
}
const isDraft = invoice?.status === "Draft"
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
@@ -632,63 +608,18 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<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" ? (
<div className="flex flex-wrap items-center gap-2">
<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>
<Button size="sm" onClick={() => setPayDialogOpen(true)} disabled={invoice.totals.balanceAmount <= 0}>
{invoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
</Button>
</div>
<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>
)}
{invoice.status === "Posted" && (
<div className="mt-5 flex flex-col gap-2 rounded-2xl border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="font-semibold text-foreground">Payment status</span>
<span className="text-muted-foreground">
Paid {money.format(invoice.totals.paidAmount)} of {money.format(invoice.totals.netPayable)} — balance {money.format(invoice.totals.balanceAmount)}
{invoice.glJournalNo ? ` · GL journal ${invoice.glJournalNo}` : ""}
</span>
</div>
{payments.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="py-1 pr-3">Date</th>
<th className="py-1 pr-3">Account</th>
<th className="py-1 pr-3">Reference</th>
<th className="py-1 pr-3">GL journal</th>
<th className="py-1 text-right">Amount</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.salesInvoicePaymentId} className="border-t border-border">
<td className="py-2 pr-3">{new Date(p.paymentDate).toLocaleString()}</td>
<td className="py-2 pr-3">{p.bankAccountName}</td>
<td className="py-2 pr-3">{p.reference ?? "—"}</td>
<td className="py-2 pr-3">{p.glJournalNo ?? "—"}</td>
<td className="py-2 text-right font-mono tabular-nums">{money.format(p.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</section>
<SalesInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
</div>
)
}
@@ -1,5 +1,5 @@
import Link from "next/link"
import { FileBarChart, FileText, Lock, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@@ -23,12 +23,6 @@ const sections = [
href: "/dashboard/sales/free-issues",
icon: PackageX,
},
{
title: "Day End",
description: "Close a cashier's day and post it to the ledger.",
href: "/dashboard/sales/day-end",
icon: Lock,
},
// {
// title: "Reports",
// description: "Sales report catalog and query entry point.",
@@ -94,7 +94,6 @@ export default function SalesReturnsListPage() {
<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">GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
@@ -111,7 +110,6 @@ export default function SalesReturnsListPage() {
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -81,7 +81,6 @@ export default function AdjustmentsListPage() {
<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-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
@@ -94,7 +93,6 @@ export default function AdjustmentsListPage() {
<TableCell className="px-3 py-3.5">
<AdjustmentStatusBadge status={a.status} />
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -124,9 +124,6 @@ export default function CountDetailPage() {
<p className="text-base font-semibold">Posted — variance adjustment #{postResult.adjustmentId} created</p>
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
{postResult.glJournalNo && (
<div className="text-sm text-muted-foreground">GL journal: <span className="font-medium text-foreground">{postResult.glJournalNo}</span></div>
)}
</div>
)}
+3 -20
View File
@@ -8,7 +8,6 @@ import { AlertTriangle, Save } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -40,22 +39,6 @@ export default function VendorDetailPage() {
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
// Every other vendor's code, to de-dupe against when the name edit regenerates this
// vendor's own code (mirrors the create dialog's `allVendorCodes`, app/dashboard/vendors/page.tsx).
const [otherVendorCodes, setOtherVendorCodes] = useState<string[]>([])
useEffect(() => {
vendorsApi
.list({ pageSize: 200 })
.then((res) => setOtherVendorCodes(res.items.filter((v) => v.vendorId !== vendorId).map((v) => v.code)))
.catch(() => {})
}, [vendorId])
function handleNameChange(value: string) {
setName(value)
setCode(value.trim() ? generateVendorCode(value, otherVendorCodes) : "")
}
function load() {
setLoadError(null)
vendorsApi
@@ -194,13 +177,13 @@ export default function VendorDetailPage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Code (auto-generated)</Label>
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
<Label className="text-base">Code</Label>
<Input value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
@@ -1,145 +0,0 @@
"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,32 +32,3 @@ 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>
)
}
@@ -1,143 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
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 SalesInvoicePaymentDialogProps {
invoice: SalesInvoice | null
open: boolean
onOpenChange: (open: boolean) => void
onPaid: (invoice: SalesInvoice, payment: SalesInvoicePayment) => void
}
/** Record a customer payment against a posted invoice's balance — full or partial, into an
* existing GL cash/bank account. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
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 invoice) during render — same
// "adjust state while rendering" pattern as GrnPaymentDialog, not inside an effect.
const openKey = open && invoice ? `${invoice.salesInvoiceId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setAmount(invoice!.totals.balanceAmount.toFixed(2))
setGlBankAccountId("")
setReference("")
setErrors({})
}
useEffect(() => {
if (!open || accounts !== null) return
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
}, [open, accounts])
if (!invoice) return null
async function submit() {
if (!invoice) return
const nextErrors: Record<string, string> = {}
const amountNum = Number(amount)
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay into"
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
amount: amountNum,
glBankAccountId: Number(glBankAccountId),
reference: reference || undefined,
})
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
onPaid(invoice, 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 invoice {invoice.invoiceNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
</p>
<FieldGroup>
<Field data-invalid={!!errors.glBankAccountId}>
<FieldLabel htmlFor="inv-pay-account">Pay into</FieldLabel>
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
<SelectTrigger id="inv-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="inv-pay-amount">Amount</FieldLabel>
<Input
id="inv-pay-amount"
type="number"
step="0.01"
min="0.01"
max={invoice.totals.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="inv-pay-reference">Reference (optional)</FieldLabel>
<Input id="inv-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>
)
}
@@ -1,95 +0,0 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
import { ChevronDownIcon, CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export interface ComboboxOption<Value> {
value: Value
label: string
}
interface ComboboxProps<Value> {
items: ComboboxOption<Value>[]
value: Value | null
onValueChange: (value: Value | null) => void
placeholder?: string
emptyText?: string
disabled?: boolean
className?: string
"aria-invalid"?: boolean
}
/** Searchable dropdown for long option lists (items, vendors, …) — a `Select` swap-in
* built on Base UI's Combobox, styled to match `select.tsx`'s trigger/popup/item look. */
export function Combobox<Value>({
items,
value,
onValueChange,
placeholder = "Search…",
emptyText = "No results found.",
disabled,
className,
...rest
}: ComboboxProps<Value>) {
const itemToStringLabel = React.useCallback(
(v: Value) => items.find((i) => i.value === v)?.label ?? "",
[items]
)
return (
<ComboboxPrimitive.Root<Value>
items={items}
value={value}
onValueChange={(v) => onValueChange(v ?? null)}
itemToStringLabel={itemToStringLabel}
disabled={disabled}
>
<ComboboxPrimitive.InputGroup
data-slot="combobox-input-group"
className={cn(
"flex h-9 w-full items-center gap-1.5 rounded-lg border border-input bg-transparent pr-2 pl-2.5 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-disabled:cursor-not-allowed has-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30",
className
)}
{...rest}
>
<ComboboxPrimitive.Input
placeholder={placeholder}
disabled={disabled}
className="h-full w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
<ComboboxPrimitive.Icon
render={<ChevronDownIcon className="pointer-events-none size-4 shrink-0 text-muted-foreground" />}
/>
</ComboboxPrimitive.InputGroup>
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner side="bottom" sideOffset={4} className="isolate z-50">
<ComboboxPrimitive.Popup
data-slot="combobox-content"
className="max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
>
<ComboboxPrimitive.Empty className="px-2.5 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</ComboboxPrimitive.Empty>
<ComboboxPrimitive.List>
{(item: ComboboxOption<Value>) => (
<ComboboxPrimitive.Item
key={String(item.value)}
value={item.value}
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-violet-100 data-highlighted:text-violet-900 dark:data-highlighted:bg-violet-500/25 dark:data-highlighted:text-violet-200"
>
{item.label}
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)}
</ComboboxPrimitive.List>
</ComboboxPrimitive.Popup>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
</ComboboxPrimitive.Root>
)
}
-12
View File
@@ -10,10 +10,8 @@ import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ConfirmGrnResponse,
CreateGrnPaymentRequest,
CreateGrnRequest,
Grn,
GrnPayment,
GrnStatus,
GrnSummary,
ReleaseAction,
@@ -60,14 +58,4 @@ 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`)
},
}
@@ -1,31 +0,0 @@
// Cashier day-end close-out (docs/14 Sales API). Preview shows what closing right now
// would include (or the existing close, if already closed); close is the one-shot,
// idempotent action that locks the day's Posted slips and posts the consolidated GL entry.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateSalesDayEndRequest, SalesDayEnd, SalesDayEndPreview, SalesDayEndSummary } from "@/types/sales"
export interface ListSalesDayEndsParams {
page?: number
pageSize?: number
cashierUserId?: number
}
export const salesDayEndApi = {
list(params: ListSalesDayEndsParams = {}): Promise<PagedResponse<SalesDayEndSummary>> {
return apiRequest<PagedResponse<SalesDayEndSummary>>(`/sales-day-end${buildQuery(params)}`)
},
get(salesDayEndId: number): Promise<SalesDayEnd> {
return apiRequest<SalesDayEnd>(`/sales-day-end/${salesDayEndId}`)
},
preview(cashierUserId: number, businessDate?: string): Promise<SalesDayEndPreview> {
return apiRequest<SalesDayEndPreview>(`/sales-day-end/preview${buildQuery({ cashierUserId, businessDate })}`)
},
/** Idempotent — closing an already-closed date replays the existing record. */
close(request: CreateSalesDayEndRequest): Promise<SalesDayEnd> {
return apiRequest<SalesDayEnd>("/sales-day-end", { method: "POST", body: request })
},
}
-12
View File
@@ -1,12 +1,10 @@
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CreateSalesInvoicePaymentRequest,
CreateSalesInvoiceRequest,
CreateSalesSlipRequest,
SalesInvoice,
SalesInvoiceStatus,
SalesInvoicePayment,
SalesInvoicePostingCheck,
SalesInvoiceSummary,
SalesListFilterParams,
@@ -67,16 +65,6 @@ export const salesApi = {
return apiRequest<SalesInvoice>(`/sales-invoices/${salesInvoiceId}/cancel`, { method: "POST" })
},
/** Pay the customer's balance against a posted invoice, in full or in installments. Posts a real GL journal entry. */
payInvoice(salesInvoiceId: number, request: CreateSalesInvoicePaymentRequest): Promise<SalesInvoicePayment> {
return apiRequest<SalesInvoicePayment>(`/sales-invoices/${salesInvoiceId}/payments`, { method: "POST", body: request })
},
/** Payment history for an invoice, newest first. */
listInvoicePayments(salesInvoiceId: number): Promise<SalesInvoicePayment[]> {
return apiRequest<SalesInvoicePayment[]>(`/sales-invoices/${salesInvoiceId}/payments`)
},
listSlips(params: ListSalesSlipsParams = {}): Promise<PagedResponse<SalesSlipSummary>> {
return apiRequest<PagedResponse<SalesSlipSummary>>(`/sales-slips${buildQuery(params)}`)
},
@@ -288,8 +288,6 @@ 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
}
-28
View File
@@ -100,10 +100,6 @@ 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[]
}
@@ -118,8 +114,6 @@ export interface GrnSummary {
createdAt: string
postedAt: string | null
lineCount: number
paidAmount: number
balanceAmount: number
}
/** docs/11 §4.2 confirm response. */
@@ -138,8 +132,6 @@ export interface ConfirmGrnResponse {
grnId: number
status: GrnStatus
postedAt: string
glJournalNo: string
balanceAmount: number
createdLayers: CreatedLayer[]
ledgerRefs: number[]
poStatus: PurchaseOrderStatus | null
@@ -155,23 +147,3 @@ 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
}
-3
View File
@@ -219,8 +219,6 @@ export interface PurchaseReturn {
status: PurchaseReturnStatus
createdBy: number
createdAt: string
glJournalNo: string | null
glPostedAt: string | null
lines: PurchaseReturnLine[]
ledgerRefs: number[]
}
@@ -235,7 +233,6 @@ export interface PurchaseReturnSummary {
createdBy: number
createdAt: string
lineCount: number
glJournalNo: string | null
}
export interface CreatePurchaseReturnLineInput {
+2
View File
@@ -338,6 +338,8 @@ export interface CreateRunRequest {
export interface StageQuantityLine {
id: number
plannedQty: number
/** Required only when this lowers a running Stock input below what it has already consumed. */
reasonCodeId?: number | null
}
export interface UpdateStageQuantitiesRequest {
-99
View File
@@ -49,7 +49,6 @@ export interface SalesInvoiceSummary {
status: SalesInvoiceStatus
totals: SalesInvoiceTotals
createdAt: string
glJournalNo: string | null
}
export interface SalesInvoicePostingIssue {
@@ -96,28 +95,9 @@ export interface SalesInvoice extends SalesInvoiceSummary {
customerSnapshotTaxNo: string | null
createdBy: number
updatedAt: string | null
glPostedAt: string | null
lines: SalesInvoiceLine[]
}
export interface SalesInvoicePayment {
salesInvoicePaymentId: number
salesInvoiceId: number
amount: number
paymentDate: string
glBankAccountId: number
bankAccountName: string
reference: string | null
glJournalNo: string | null
createdAt: string
}
export interface CreateSalesInvoicePaymentRequest {
amount: number
glBankAccountId: number
reference?: string | null
}
export interface CreateSalesInvoiceLineRequest {
itemId: number
warehouseId: number
@@ -349,8 +329,6 @@ export interface SalesReturn {
status: SalesReturnStatus
createdBy: number
createdAt: string
glJournalNo: string | null
glPostedAt: string | null
lines: SalesReturnLine[]
ledgerRefs: number[]
}
@@ -366,7 +344,6 @@ export interface SalesReturnSummary {
createdAt: string
lineCount: number
totalQty: number
glJournalNo: string | null
}
/** Remaining returnable qty for one sales invoice line (invoiced qty minus already-returned). */
@@ -388,79 +365,3 @@ export interface CreateSalesReturnRequest {
reasonCodeId: number
lines: CreateSalesReturnLineInput[]
}
// Sales Day End -------------------------------------------------------------------
export interface SalesDayEndItemLine {
itemId: number
sku: string
name: string
qty: number
revenue: number
}
export interface SalesDayEnd {
salesDayEndId: number
docNo: string
cashierUserId: number
businessDate: string
slipCount: number
bundleCount: number
subtotal: number
discountTotal: number
taxTotal: number
grandTotal: number
costOfGoodsSold: number
glJournalNo: string | null
glPostedAt: string | null
closedBy: number
closedAt: string
slipNumbers: string[]
bundleNumbers: string[]
itemBreakdown: SalesDayEndItemLine[]
}
export interface SalesDayEndSummary {
salesDayEndId: number
docNo: string
cashierUserId: number
businessDate: string
slipCount: number
bundleCount: number
grandTotal: number
glJournalNo: string | null
closedAt: string
}
export interface DraftSlipBlockingClose {
salesSlipId: number
slipNo: string
grandTotal: number
}
export interface DraftBundleBlockingClose {
bundleSaleId: number
bundleNo: string
grandTotal: number
}
export interface SalesDayEndPreview {
cashierUserId: number
businessDate: string
alreadyClosed: boolean
slipCount: number
bundleCount: number
subtotal: number
discountTotal: number
taxTotal: number
grandTotal: number
slipNumbers: string[]
bundleNumbers: string[]
draftSlipsBlockingClose: DraftSlipBlockingClose[]
draftBundleSalesBlockingClose: DraftBundleBlockingClose[]
}
export interface CreateSalesDayEndRequest {
cashierUserId: number
businessDate?: string | null
}
-5
View File
@@ -173,8 +173,6 @@ export interface StockAdjustment {
status: AdjustmentStatus
createdBy: number
createdAt: string
glJournalNo: string | null
glPostedAt: string | null
lines: AdjustmentLine[]
ledgerRefs: number[]
}
@@ -188,7 +186,6 @@ export interface StockAdjustmentSummary {
createdBy: number
createdAt: string
lineCount: number
glJournalNo: string | null
}
export interface CreateAdjustmentLineInput {
@@ -261,8 +258,6 @@ export interface PostCountResponse {
status: CountStatus
/** Null when the count had no variance to post. */
adjustmentId: number | null
/** Null when there was no variance, or the variance's value was 0 either way. */
glJournalNo: string | null
ledgerRefs: number[]
}