intigrate others sales , return and implement day end duntinalities
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
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,8 +10,13 @@ namespace ERPCore.Controllers;
|
|||||||
public sealed class SalesInvoicesController : ApiControllerBase
|
public sealed class SalesInvoicesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISalesInvoiceService _invoices;
|
private readonly ISalesInvoiceService _invoices;
|
||||||
|
private readonly ISalesInvoicePaymentService _payments;
|
||||||
|
|
||||||
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
|
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||||
|
{
|
||||||
|
_invoices = invoices;
|
||||||
|
_payments = payments;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||||
@@ -70,4 +75,23 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,7 @@ public static class DocumentTypes
|
|||||||
public const string SalesSlip = "SSL";
|
public const string SalesSlip = "SSL";
|
||||||
public const string BundleSale = "BND";
|
public const string BundleSale = "BND";
|
||||||
public const string SalesReturn = "SRET";
|
public const string SalesReturn = "SRET";
|
||||||
|
|
||||||
|
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
||||||
|
public const string SalesDayEnd = "DEND";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,11 @@ public class BundleSale
|
|||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public int ConcurrencyStamp { 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>();
|
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,12 @@ public class PurchaseReturn
|
|||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
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>();
|
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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,6 +29,14 @@ public class SalesInvoice
|
|||||||
public decimal PaidAmount { get; set; }
|
public decimal PaidAmount { get; set; }
|
||||||
public decimal BalanceAmount { 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 int CreatedBy { get; set; }
|
||||||
public User? Creator { get; set; }
|
public User? Creator { get; set; }
|
||||||
|
|
||||||
@@ -38,4 +46,5 @@ public class SalesInvoice
|
|||||||
public uint RowVersion { get; set; }
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
||||||
|
public ICollection<SalesInvoicePayment> Payments { get; set; } = new List<SalesInvoicePayment>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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,5 +28,11 @@ public class SalesReturn
|
|||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
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>();
|
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ public class SalesSlip
|
|||||||
public decimal PaidAmount { get; set; }
|
public decimal PaidAmount { get; set; }
|
||||||
public decimal BalanceAmount { 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 CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -27,5 +27,10 @@ public class StockAdjustment
|
|||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public uint RowVersion { 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>();
|
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
|
|||||||
|
|
||||||
public sealed record PurchaseReturnDto(
|
public sealed record PurchaseReturnDto(
|
||||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
|
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>
|
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||||
public sealed record PurchaseReturnSummaryDto(
|
public sealed record PurchaseReturnSummaryDto(
|
||||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
||||||
|
|
||||||
// Requests ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -17,12 +17,24 @@ public sealed record SalesInvoiceDto(
|
|||||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||||
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
|
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
|
||||||
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
|
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
|
||||||
DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
|
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
|
||||||
|
|
||||||
public sealed record SalesInvoiceSummaryDto(
|
public sealed record SalesInvoiceSummaryDto(
|
||||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record SalesInvoicePostingIssueDto(
|
public sealed record SalesInvoicePostingIssueDto(
|
||||||
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineI
|
|||||||
|
|
||||||
public sealed record SalesReturnDto(
|
public sealed record SalesReturnDto(
|
||||||
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<SalesReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
|
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>
|
/// <summary>Row shape for <c>GET /sales-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||||
public sealed record SalesReturnSummaryDto(
|
public sealed record SalesReturnSummaryDto(
|
||||||
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty);
|
int CreatedBy, DateTime CreatedAt, int LineCount, decimal TotalQty, string? GlJournalNo);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
|
/// Remaining returnable qty for one sales invoice line — the invoiced qty minus
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, in
|
|||||||
|
|
||||||
public sealed record AdjustmentDto(
|
public sealed record AdjustmentDto(
|
||||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
|
||||||
|
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>
|
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||||
public sealed record AdjustmentSummaryDto(
|
public sealed record AdjustmentSummaryDto(
|
||||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
|
||||||
|
|
||||||
// Requests ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public sealed record CountSummaryDto(
|
|||||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||||
|
|
||||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
|
||||||
|
|
||||||
// Requests ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration<Purch
|
|||||||
|
|
||||||
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
builder.Property(r => r.CreatedAt).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.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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,6 +40,7 @@ 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) })
|
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<decimal>(p).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||||
|
|
||||||
builder.Property(x => x.CreatedAt).IsRequired();
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||||
@@ -54,6 +55,27 @@ 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 sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<SalesInvoiceLine>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<SalesInvoiceLine> builder)
|
public void Configure(EntityTypeBuilder<SalesInvoiceLine> builder)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class SalesReturnConfiguration : IEntityTypeConfiguration<SalesRet
|
|||||||
|
|
||||||
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
builder.Property(r => r.CreatedAt).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.Customer).WithMany().HasForeignKey(r => r.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
|
|||||||
|
|
||||||
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
builder.Property(a => a.CreatedAt).IsRequired();
|
builder.Property(a => a.CreatedAt).IsRequired();
|
||||||
|
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
|
||||||
builder.Property(a => a.RowVersion).IsRowVersion();
|
builder.Property(a => a.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ public class ErpDbContext : DbContext
|
|||||||
// --- Sales (Phase 1) ---
|
// --- Sales (Phase 1) ---
|
||||||
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
||||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||||
|
public DbSet<SalesInvoicePayment> SalesInvoicePayments => Set<SalesInvoicePayment>();
|
||||||
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||||
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
||||||
|
public DbSet<SalesDayEnd> SalesDayEnds => Set<SalesDayEnd>();
|
||||||
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
|
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
|
||||||
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
||||||
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
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
@@ -0,0 +1,61 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7372
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7391
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
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,6 +422,9 @@ namespace ERPCore.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("DayEndId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<decimal>("DiscountTotal")
|
b.Property<decimal>("DiscountTotal")
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("numeric(18,4)");
|
.HasColumnType("numeric(18,4)");
|
||||||
@@ -462,6 +465,8 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CustomerId");
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("DayEndId");
|
||||||
|
|
||||||
b.HasIndex("WarehouseId");
|
b.HasIndex("WarehouseId");
|
||||||
|
|
||||||
b.ToTable("bundle_sales", (string)null);
|
b.ToTable("bundle_sales", (string)null);
|
||||||
@@ -1552,6 +1557,33 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("grn_lines", (string)null);
|
b.ToTable("grn_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("GrnLineWarrantyNumberId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnLineWarrantyNumberId"));
|
||||||
|
|
||||||
|
b.Property<int>("GrnLineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("WarrantyNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("WarrantyPeriodMonths")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("GrnLineWarrantyNumberId");
|
||||||
|
|
||||||
|
b.HasIndex("GrnLineId", "WarrantyNo")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("grn_line_warranty_numbers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("GrnPaymentId")
|
b.Property<int>("GrnPaymentId")
|
||||||
@@ -1601,33 +1633,6 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("grn_payments", (string)null);
|
b.ToTable("grn_payments", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("GrnLineWarrantyNumberId")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnLineWarrantyNumberId"));
|
|
||||||
|
|
||||||
b.Property<int>("GrnLineId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("WarrantyNo")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<int>("WarrantyPeriodMonths")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("GrnLineWarrantyNumberId");
|
|
||||||
|
|
||||||
b.HasIndex("GrnLineId", "WarrantyNo")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("grn_line_warranty_numbers", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("HrDocumentTypeId")
|
b.Property<int>("HrDocumentTypeId")
|
||||||
@@ -3171,6 +3176,13 @@ namespace ERPCore.Migrations
|
|||||||
.HasMaxLength(30)
|
.HasMaxLength(30)
|
||||||
.HasColumnType("character varying(30)");
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("GlJournalNo")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("GlPostedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("ReasonCodeId")
|
b.Property<int>("ReasonCodeId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -3776,6 +3788,85 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("hr_salary_components", (string)null);
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("SalesInvoiceId")
|
b.Property<int>("SalesInvoiceId")
|
||||||
@@ -3813,6 +3904,13 @@ namespace ERPCore.Migrations
|
|||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("numeric(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")
|
b.Property<decimal>("GrandTotal")
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("numeric(18,4)");
|
.HasColumnType("numeric(18,4)");
|
||||||
@@ -3982,85 +4080,53 @@ namespace ERPCore.Migrations
|
|||||||
b.ToTable("sales_invoice_lines", (string)null);
|
b.ToTable("sales_invoice_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("ReturnId")
|
b.Property<int>("SalesInvoicePaymentId")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnId"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesInvoicePaymentId"));
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<decimal>("Amount")
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<int>("CreatedBy")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("CustomerId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("DocNo")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(30)
|
|
||||||
.HasColumnType("character varying(30)");
|
|
||||||
|
|
||||||
b.Property<int>("ReasonCodeId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Status")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<int>("WarehouseId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("ReturnId");
|
|
||||||
|
|
||||||
b.HasIndex("CreatedBy");
|
|
||||||
|
|
||||||
b.HasIndex("CustomerId");
|
|
||||||
|
|
||||||
b.HasIndex("DocNo")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("ReasonCodeId");
|
|
||||||
|
|
||||||
b.HasIndex("WarehouseId");
|
|
||||||
|
|
||||||
b.ToTable("sales_returns", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("ReturnLineId")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnLineId"));
|
|
||||||
|
|
||||||
b.Property<int>("ItemId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("Qty")
|
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("numeric(18,4)");
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
b.Property<int>("ReturnId")
|
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");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("SalesInvoiceLineId")
|
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");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.HasKey("ReturnLineId");
|
b.HasKey("SalesInvoicePaymentId");
|
||||||
|
|
||||||
b.HasIndex("ItemId");
|
b.HasIndex("CreatedBy");
|
||||||
|
|
||||||
b.HasIndex("ReturnId");
|
b.HasIndex("SalesInvoiceId");
|
||||||
|
|
||||||
b.HasIndex("SalesInvoiceLineId");
|
b.ToTable("sales_invoice_payments", (string)null);
|
||||||
|
|
||||||
b.ToTable("sales_return_lines", (string)null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||||
@@ -4085,6 +4151,13 @@ namespace ERPCore.Migrations
|
|||||||
.HasMaxLength(30)
|
.HasMaxLength(30)
|
||||||
.HasColumnType("character varying(30)");
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("GlJournalNo")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("GlPostedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("ReasonCodeId")
|
b.Property<int>("ReasonCodeId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -4170,6 +4243,9 @@ namespace ERPCore.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("DayEndId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<decimal>("DiscountTotal")
|
b.Property<decimal>("DiscountTotal")
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("numeric(18,4)");
|
.HasColumnType("numeric(18,4)");
|
||||||
@@ -4223,6 +4299,8 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CustomerId");
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("DayEndId");
|
||||||
|
|
||||||
b.HasIndex("SlipDate");
|
b.HasIndex("SlipDate");
|
||||||
|
|
||||||
b.HasIndex("SlipNo")
|
b.HasIndex("SlipNo")
|
||||||
@@ -4484,6 +4562,13 @@ namespace ERPCore.Migrations
|
|||||||
.HasMaxLength(30)
|
.HasMaxLength(30)
|
||||||
.HasColumnType("character varying(30)");
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("GlJournalNo")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("GlPostedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("ReasonCodeId")
|
b.Property<int>("ReasonCodeId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -5672,6 +5757,11 @@ namespace ERPCore.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
|
||||||
|
.WithMany("BundleSales")
|
||||||
|
.HasForeignKey("DayEndId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("WarehouseId")
|
.HasForeignKey("WarehouseId")
|
||||||
@@ -5684,6 +5774,8 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
b.Navigation("Customer");
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("DayEnd");
|
||||||
|
|
||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5976,6 +6068,17 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("PoLine");
|
b.Navigation("PoLine");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
||||||
|
.WithMany("WarrantyNumbers")
|
||||||
|
.HasForeignKey("GrnLineId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("GrnLine");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.GrnPayment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
@@ -5995,17 +6098,6 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Grn");
|
b.Navigation("Grn");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
|
||||||
.WithMany("WarrantyNumbers")
|
|
||||||
.HasForeignKey("GrnLineId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("GrnLine");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
|
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
|
||||||
@@ -6579,6 +6671,25 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Uom");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
@@ -6631,7 +6742,7 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -6639,57 +6750,15 @@ namespace ERPCore.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice")
|
||||||
.WithMany()
|
.WithMany("Payments")
|
||||||
.HasForeignKey("CustomerId")
|
.HasForeignKey("SalesInvoiceId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ReasonCodeId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("WarehouseId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Creator");
|
b.Navigation("Creator");
|
||||||
|
|
||||||
b.Navigation("Customer");
|
b.Navigation("SalesInvoice");
|
||||||
|
|
||||||
b.Navigation("ReasonCode");
|
|
||||||
|
|
||||||
b.Navigation("Warehouse");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ItemId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return")
|
|
||||||
.WithMany("Lines")
|
|
||||||
.HasForeignKey("ReturnId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("SalesInvoiceLineId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.Navigation("Item");
|
|
||||||
|
|
||||||
b.Navigation("Return");
|
|
||||||
|
|
||||||
b.Navigation("SalesInvoiceLine");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||||
@@ -6767,6 +6836,11 @@ namespace ERPCore.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
|
||||||
|
.WithMany("Slips")
|
||||||
|
.HasForeignKey("DayEndId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("WarehouseId")
|
.HasForeignKey("WarehouseId")
|
||||||
@@ -6777,6 +6851,8 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
b.Navigation("Customer");
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("DayEnd");
|
||||||
|
|
||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7259,11 +7335,6 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("WarrantyNumbers");
|
b.Navigation("WarrantyNumbers");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("WarrantyNumbers");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("ReorderSettings");
|
b.Navigation("ReorderSettings");
|
||||||
@@ -7333,14 +7404,18 @@ namespace ERPCore.Migrations
|
|||||||
b.Navigation("Outputs");
|
b.Navigation("Outputs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BundleSales");
|
||||||
|
|
||||||
|
b.Navigation("Slips");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
b.Navigation("Payments");
|
||||||
{
|
|
||||||
b.Navigation("Lines");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||||
|
|||||||
@@ -112,11 +112,13 @@ builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
|||||||
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
||||||
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
||||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||||
|
builder.Services.AddScoped<ISalesInvoicePaymentService, SalesInvoicePaymentService>();
|
||||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||||
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
||||||
|
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
|
||||||
|
|
||||||
// Stock transactions + reference data (docs/11 §5–6)
|
// Stock transactions + reference data (docs/11 §5–6)
|
||||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Stock;
|
|||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -16,7 +17,10 @@ namespace ERPCore.Services;
|
|||||||
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
|
/// 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
|
/// 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
|
/// decrease, layer create on an increase) + ledger posting is delegated to
|
||||||
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05).
|
/// <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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AdjustmentService : IAdjustmentService
|
public sealed class AdjustmentService : IAdjustmentService
|
||||||
{
|
{
|
||||||
@@ -29,11 +33,16 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly string _glInventoryAccountCode;
|
||||||
|
private readonly string _glGainAccountCode;
|
||||||
|
private readonly string _glLossAccountCode;
|
||||||
|
|
||||||
public AdjustmentService(
|
public AdjustmentService(
|
||||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||||
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||||
|
IGeneralLedgerService gl, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_adjustments = adjustments;
|
_adjustments = adjustments;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
@@ -44,6 +53,45 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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(
|
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||||
@@ -64,7 +112,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(a => new AdjustmentSummaryDto(
|
.Select(a => new AdjustmentSummaryDto(
|
||||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
|
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
|
||||||
a.CreatedBy, a.CreatedAt, a.Lines.Count))
|
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -136,6 +184,10 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
|
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);
|
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);
|
return (entity, refs);
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
@@ -144,6 +196,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
|
|
||||||
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
|
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.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
||||||
|
a.GlJournalNo, a.GlPostedAt,
|
||||||
a.Lines.OrderBy(l => l.AdjLineId)
|
a.Lines.OrderBy(l => l.AdjLineId)
|
||||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
||||||
ledgerRefs);
|
ledgerRefs);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
{
|
{
|
||||||
private readonly IRepository<BundleSaleTemplate> _templates;
|
private readonly IRepository<BundleSaleTemplate> _templates;
|
||||||
private readonly IRepository<BundleSale> _bundles;
|
private readonly IRepository<BundleSale> _bundles;
|
||||||
|
private readonly IRepository<SalesDayEnd> _dayEnds;
|
||||||
private readonly IRepository<Customer> _customers;
|
private readonly IRepository<Customer> _customers;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
@@ -30,6 +31,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
public BundleSaleService(
|
public BundleSaleService(
|
||||||
IRepository<BundleSale> bundles,
|
IRepository<BundleSale> bundles,
|
||||||
IRepository<BundleSaleTemplate> templates,
|
IRepository<BundleSaleTemplate> templates,
|
||||||
|
IRepository<SalesDayEnd> dayEnds,
|
||||||
IRepository<Customer> customers,
|
IRepository<Customer> customers,
|
||||||
IRepository<Item> items,
|
IRepository<Item> items,
|
||||||
IRepository<Warehouse> warehouses,
|
IRepository<Warehouse> warehouses,
|
||||||
@@ -42,6 +44,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
{
|
{
|
||||||
_templates = templates;
|
_templates = templates;
|
||||||
_bundles = bundles;
|
_bundles = bundles;
|
||||||
|
_dayEnds = dayEnds;
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
_items = items;
|
_items = items;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
@@ -110,6 +113,15 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
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)
|
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||||
var bundle = new BundleSale
|
var bundle = new BundleSale
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Stock;
|
|||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -16,7 +17,9 @@ namespace ERPCore.Services;
|
|||||||
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
|
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
|
||||||
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
|
/// 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)
|
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
|
||||||
/// and closes the count — all in one UoW transaction.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CountService : ICountService
|
public sealed class CountService : ICountService
|
||||||
{
|
{
|
||||||
@@ -32,11 +35,16 @@ public sealed class CountService : ICountService
|
|||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly string _glInventoryAccountCode;
|
||||||
|
private readonly string _glGainAccountCode;
|
||||||
|
private readonly string _glLossAccountCode;
|
||||||
|
|
||||||
public CountService(
|
public CountService(
|
||||||
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||||
|
IGeneralLedgerService gl, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_counts = counts;
|
_counts = counts;
|
||||||
_adjustments = adjustments;
|
_adjustments = adjustments;
|
||||||
@@ -48,6 +56,44 @@ public sealed class CountService : ICountService
|
|||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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(
|
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||||
@@ -160,7 +206,7 @@ public sealed class CountService : ICountService
|
|||||||
{
|
{
|
||||||
count.Status = CountStatus.Posted;
|
count.Status = CountStatus.Posted;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return new CountPostResultDto(count.CountId, count.Status, null, Array.Empty<int>());
|
return new CountPostResultDto(count.CountId, count.Status, null, null, Array.Empty<int>());
|
||||||
}
|
}
|
||||||
|
|
||||||
var reason = await _reasonCodes.Query().AsNoTracking()
|
var reason = await _reasonCodes.Query().AsNoTracking()
|
||||||
@@ -168,7 +214,7 @@ public sealed class CountService : ICountService
|
|||||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var (adjustmentId, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
var (adjustmentId, glJournalNo, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
||||||
var adjustment = new StockAdjustment
|
var adjustment = new StockAdjustment
|
||||||
@@ -191,12 +237,15 @@ public sealed class CountService : ICountService
|
|||||||
|
|
||||||
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
|
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;
|
count.Status = CountStatus.Posted;
|
||||||
return (adjustment.AdjustmentId, refs);
|
return (adjustment.AdjustmentId, adjustment.GlJournalNo, refs);
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
// Map ledger ids after commit so they are populated.
|
// Map ledger ids after commit so they are populated.
|
||||||
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, ledgerEntries.Select(r => r.LedgerId).ToList());
|
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, glJournalNo, ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CountDto Map(StockCount c) => new(
|
private static CountDto Map(StockCount c) => new(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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,6 +16,17 @@ namespace ERPCore.Services.Production;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19).
|
/// 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>
|
/// </summary>
|
||||||
public sealed class ProductionRunService : IProductionRunService
|
public sealed class ProductionRunService : IProductionRunService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Procurement;
|
|||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -16,7 +17,9 @@ namespace ERPCore.Services;
|
|||||||
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
|
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
|
||||||
/// code and generates an outbound stock movement via the shared
|
/// code and generates an outbound stock movement via the shared
|
||||||
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
|
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
|
||||||
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PurchaseReturnService : IPurchaseReturnService
|
public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||||
{
|
{
|
||||||
@@ -31,12 +34,15 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly string _glInventoryAccountCode;
|
||||||
|
private readonly string _glClearingAccountCode;
|
||||||
|
|
||||||
public PurchaseReturnService(
|
public PurchaseReturnService(
|
||||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
||||||
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
|
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
|
||||||
ICurrentUser currentUser, IUnitOfWork uow)
|
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_returns = returns;
|
_returns = returns;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
@@ -49,6 +55,9 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
|
_gl = gl;
|
||||||
|
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||||
|
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
|
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
|
||||||
@@ -69,7 +78,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(r => new PurchaseReturnSummaryDto(
|
.Select(r => new PurchaseReturnSummaryDto(
|
||||||
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
|
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
|
||||||
r.CreatedBy, r.CreatedAt, r.Lines.Count))
|
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.GlJournalNo))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -146,6 +155,28 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
|
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 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);
|
return (ret, refs);
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
@@ -155,6 +186,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
|
|
||||||
private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList<int> ledgerRefs) => new(
|
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.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)
|
r.Lines.OrderBy(l => l.ReturnLineId)
|
||||||
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
|
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
|
||||||
ledgerRefs);
|
ledgerRefs);
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -80,24 +80,32 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||||
var invoice = new SalesInvoice
|
|
||||||
{
|
|
||||||
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);
|
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
await _uow.SaveChangesAsync(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 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);
|
||||||
|
|
||||||
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,5 +198,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
|
|
||||||
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
||||||
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt);
|
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class SalesMappingService : ISalesMappingService
|
|||||||
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
|
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
|
||||||
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
|
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
|
||||||
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
|
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
|
||||||
|
invoice.GlJournalNo, invoice.GlPostedAt,
|
||||||
MapInvoiceTotals(invoice),
|
MapInvoiceTotals(invoice),
|
||||||
invoice.Lines.Select(l => new SalesInvoiceLineDto(
|
invoice.Lines.Select(l => new SalesInvoiceLineDto(
|
||||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ERPCore.Dtos.Sales;
|
|||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.Services.Stock;
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
@@ -22,6 +23,13 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
private readonly ISalesDomainService _sales;
|
private readonly ISalesDomainService _sales;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
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(
|
public SalesPostingService(
|
||||||
IRepository<SalesInvoice> invoices,
|
IRepository<SalesInvoice> invoices,
|
||||||
@@ -31,7 +39,9 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
IFifoCostingService fifo,
|
IFifoCostingService fifo,
|
||||||
ISalesDomainService sales,
|
ISalesDomainService sales,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow,
|
||||||
|
IGeneralLedgerService gl,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_slips = slips;
|
_slips = slips;
|
||||||
@@ -41,6 +51,15 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
_sales = sales;
|
_sales = sales;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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)
|
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
@@ -143,7 +162,51 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||||
sourceDocType: DocumentTypes.SalesInvoice,
|
sourceDocType: DocumentTypes.SalesInvoice,
|
||||||
getDocId: x => x.SalesInvoiceId,
|
getDocId: x => x.SalesInvoiceId,
|
||||||
ct: ct);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
|
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||||
=> PostAsync(
|
=> PostAsync(
|
||||||
@@ -181,7 +244,11 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
Action<T> setUpdated,
|
Action<T> setUpdated,
|
||||||
string sourceDocType,
|
string sourceDocType,
|
||||||
Func<T, int> getDocId,
|
Func<T, int> getDocId,
|
||||||
CancellationToken ct)
|
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)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
var doc = await load() ?? throw new NotFoundException(notFoundMessage);
|
var doc = await load() ?? throw new NotFoundException(notFoundMessage);
|
||||||
@@ -192,6 +259,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
|
|
||||||
await _uow.ExecuteInTransactionAsync(async token =>
|
await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
|
var totalCogs = 0m;
|
||||||
foreach (var line in getLines(doc))
|
foreach (var line in getLines(doc))
|
||||||
{
|
{
|
||||||
if (line.Qty <= 0) continue;
|
if (line.Qty <= 0) continue;
|
||||||
@@ -202,12 +270,15 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
|
// 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 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);
|
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,
|
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||||
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
|
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPosted(doc);
|
setPosted(doc);
|
||||||
setUpdated(doc);
|
setUpdated(doc);
|
||||||
|
|
||||||
|
if (afterConsumption is not null) await afterConsumption(doc, totalCogs, token);
|
||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Sales;
|
|||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -17,7 +18,10 @@ namespace ERPCore.Services;
|
|||||||
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
|
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
|
||||||
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW
|
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW
|
||||||
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
|
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
|
||||||
/// reversed.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SalesReturnService : ISalesReturnService
|
public sealed class SalesReturnService : ISalesReturnService
|
||||||
{
|
{
|
||||||
@@ -33,12 +37,16 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly IGeneralLedgerService _gl;
|
||||||
|
private readonly string _glInventoryAccountCode;
|
||||||
|
private readonly string _glCogsAccountCode;
|
||||||
|
|
||||||
public SalesReturnService(
|
public SalesReturnService(
|
||||||
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
|
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
|
||||||
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
|
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||||
|
IGeneralLedgerService gl, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_returns = returns;
|
_returns = returns;
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
@@ -52,6 +60,9 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
|
_gl = gl;
|
||||||
|
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
||||||
|
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
||||||
@@ -72,7 +83,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(r => new SalesReturnSummaryDto(
|
.Select(r => new SalesReturnSummaryDto(
|
||||||
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status,
|
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.CreatedBy, r.CreatedAt, r.Lines.Count, r.Lines.Sum(l => l.Qty), r.GlJournalNo))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<SalesReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<SalesReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -164,6 +175,28 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
|
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 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);
|
return (ret, refs);
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
@@ -194,6 +227,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
|
|
||||||
private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new(
|
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.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)
|
r.Lines.OrderBy(l => l.ReturnLineId)
|
||||||
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
|
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
|
||||||
ledgerRefs);
|
ledgerRefs);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ namespace ERPCore.Services;
|
|||||||
public sealed class SalesSlipService : ISalesSlipService
|
public sealed class SalesSlipService : ISalesSlipService
|
||||||
{
|
{
|
||||||
private readonly IRepository<SalesSlip> _slips;
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
private readonly IRepository<SalesDayEnd> _dayEnds;
|
||||||
private readonly IRepository<Customer> _customers;
|
private readonly IRepository<Customer> _customers;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
@@ -31,12 +32,13 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public SalesSlipService(
|
public SalesSlipService(
|
||||||
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
IRepository<SalesSlip> slips, IRepository<SalesDayEnd> dayEnds, IRepository<Customer> customers, IRepository<Item> items,
|
||||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||||
ISalesDocumentWorkflowService workflow,
|
ISalesDocumentWorkflowService workflow,
|
||||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_slips = slips;
|
_slips = slips;
|
||||||
|
_dayEnds = dayEnds;
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
_items = items;
|
_items = items;
|
||||||
_uoms = uoms;
|
_uoms = uoms;
|
||||||
@@ -107,22 +109,37 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
{
|
{
|
||||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||||
|
|
||||||
var slip = new SalesSlip
|
// 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
|
||||||
SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, ct),
|
// day-end's totals and GL posting forever (docs/14 Sales Day End).
|
||||||
SlipDate = DateTime.UtcNow,
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
CustomerId = request.CustomerId,
|
var alreadyClosed = await _dayEnds.Query().AsNoTracking()
|
||||||
WarehouseId = request.WarehouseId,
|
.AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct);
|
||||||
CashierUserId = request.CashierUserId,
|
if (alreadyClosed)
|
||||||
Status = SalesSlipStatus.Draft,
|
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
slip.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);
|
||||||
slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
|
|
||||||
Recalculate(slip);
|
var slip = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
await _slips.AddAsync(slip, ct);
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ namespace ERPCore.Services;
|
|||||||
/// layers (row-locked; negative-stock blocked) and records the value-weighted cost
|
/// layers (row-locked; negative-stock blocked) and records the value-weighted cost
|
||||||
/// on the line; receive recreates the destination layer at that cost
|
/// on the line; receive recreates the destination layer at that cost
|
||||||
/// (cost-preserving — no revaluation). Both run in a single UoW transaction.
|
/// (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>
|
/// </summary>
|
||||||
public sealed class TransferService : ITransferService
|
public sealed class TransferService : ITransferService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,4 +77,9 @@ public static class ErrorCodes
|
|||||||
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
|
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
|
||||||
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
|
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
|
||||||
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,21 @@
|
|||||||
"GlVatRecoverableAccountCode": "1200",
|
"GlVatRecoverableAccountCode": "1200",
|
||||||
"GlClearingAccountCode": "2000"
|
"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": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
|
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
|
||||||
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
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 { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
@@ -199,7 +200,6 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!po) return
|
if (!po) return
|
||||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
@@ -280,10 +280,21 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
<Check className="size-5" />
|
<Check className="size-5" />
|
||||||
{submitting ? "Approving�" : "Approve"}
|
{submitting ? "Approving�" : "Approve"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
<AlertDialog>
|
||||||
<Trash2 className="size-5" />
|
<AlertDialogTrigger
|
||||||
{deleting ? "Deleting…" : "Delete draft"}
|
render={<Button variant="destructive" size="lg" disabled={deleting || submitting} />}
|
||||||
</Button>
|
>
|
||||||
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{cancellable && !showCancelForm && (
|
{cancellable && !showCancelForm && (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
|||||||
import { Vendor } from "@/types/master-data"
|
import { Vendor } from "@/types/master-data"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -64,7 +65,6 @@ export default function PurchaseOrdersListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(po: PurchaseOrderSummary) {
|
async function handleDelete(po: PurchaseOrderSummary) {
|
||||||
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
|
|
||||||
setDeletingId(po.poId)
|
setDeletingId(po.poId)
|
||||||
try {
|
try {
|
||||||
await purchaseOrdersApi.remove(po.poId)
|
await purchaseOrdersApi.remove(po.poId)
|
||||||
@@ -78,7 +78,6 @@ export default function PurchaseOrdersListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleApprove(po: PurchaseOrderSummary) {
|
async function handleApprove(po: PurchaseOrderSummary) {
|
||||||
if (!window.confirm(`Approve ${po.docNo}? It will be locked for editing once approved.`)) return
|
|
||||||
setApprovingId(po.poId)
|
setApprovingId(po.poId)
|
||||||
try {
|
try {
|
||||||
const updated = await purchaseOrdersApi.submit(po.poId)
|
const updated = await purchaseOrdersApi.submit(po.poId)
|
||||||
@@ -203,30 +202,54 @@ export default function PurchaseOrdersListPage() {
|
|||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<AlertDialog>
|
||||||
type="button"
|
<AlertDialogTrigger
|
||||||
variant="ghost"
|
render={
|
||||||
size="icon"
|
<Button
|
||||||
aria-label="Approve draft"
|
type="button"
|
||||||
title="Approve draft"
|
variant="ghost"
|
||||||
disabled={approvingId === po.poId || deletingId === po.poId}
|
size="icon"
|
||||||
onClick={() => handleApprove(po)}
|
aria-label="Approve draft"
|
||||||
className="text-success hover:bg-success/10 hover:text-success"
|
title="Approve draft"
|
||||||
>
|
disabled={approvingId === po.poId || deletingId === po.poId}
|
||||||
<Check className="size-4" />
|
className="text-success hover:bg-success/10 hover:text-success"
|
||||||
</Button>
|
/>
|
||||||
<Button
|
}
|
||||||
type="button"
|
>
|
||||||
variant="ghost"
|
<Check className="size-4" />
|
||||||
size="icon"
|
</AlertDialogTrigger>
|
||||||
aria-label="Delete draft"
|
<AlertDialogContent
|
||||||
title="Delete draft"
|
variant="success"
|
||||||
disabled={deletingId === po.poId || approvingId === po.poId}
|
title={`Approve ${po.docNo}?`}
|
||||||
onClick={() => handleDelete(po)}
|
description="It will be locked for editing once approved."
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
confirmLabel="Approve"
|
||||||
>
|
onConfirm={() => handleApprove(po)}
|
||||||
<Trash2 className="size-4" />
|
/>
|
||||||
</Button>
|
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default function PurchaseReturnsListPage() {
|
|||||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Reason</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">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -106,6 +107,7 @@ export default function PurchaseReturnsListPage() {
|
|||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -134,6 +134,28 @@ export default function NewGrnPage() {
|
|||||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||||
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
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 [headerError, setHeaderError] = useState<string | null>(null)
|
||||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
@@ -171,6 +193,8 @@ export default function NewGrnPage() {
|
|||||||
setPoId(null)
|
setPoId(null)
|
||||||
setVendorId(null)
|
setVendorId(null)
|
||||||
setLines([emptyLine()])
|
setLines([emptyLine()])
|
||||||
|
setHeaderDiscountPct("")
|
||||||
|
setHeaderVatPct("")
|
||||||
setHeaderError(null)
|
setHeaderError(null)
|
||||||
setLineErrors({})
|
setLineErrors({})
|
||||||
}
|
}
|
||||||
@@ -203,8 +227,8 @@ export default function NewGrnPage() {
|
|||||||
qty: String(l.qty - l.qtyReceived),
|
qty: String(l.qty - l.qtyReceived),
|
||||||
unitCost: String(l.unitPrice),
|
unitCost: String(l.unitPrice),
|
||||||
poUnitPrice: l.unitPrice,
|
poUnitPrice: l.unitPrice,
|
||||||
discountPct: "0",
|
discountPct: headerDiscountPct.trim() || "0",
|
||||||
vatPct: "0",
|
vatPct: headerVatPct.trim() || "0",
|
||||||
holdStatus: "Available",
|
holdStatus: "Available",
|
||||||
warrantyNumbers: [],
|
warrantyNumbers: [],
|
||||||
})
|
})
|
||||||
@@ -447,6 +471,38 @@ 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="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">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -460,7 +516,20 @@ export default function NewGrnPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<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
|
{/* 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). */}
|
no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */}
|
||||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...emptyLine(),
|
||||||
|
discountPct: headerDiscountPct.trim() || "0",
|
||||||
|
vatPct: headerVatPct.trim() || "0",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
Add line
|
Add line
|
||||||
</Button>
|
</Button>
|
||||||
@@ -626,6 +695,8 @@ export default function NewGrnPage() {
|
|||||||
value={line.discountPct}
|
value={line.discountPct}
|
||||||
aria-invalid={!!errors.discountPct}
|
aria-invalid={!!errors.discountPct}
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
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"
|
className="h-11 text-sm"
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||||
@@ -639,6 +710,8 @@ export default function NewGrnPage() {
|
|||||||
value={line.vatPct}
|
value={line.vatPct}
|
||||||
aria-invalid={!!errors.vatPct}
|
aria-invalid={!!errors.vatPct}
|
||||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
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"
|
className="h-11 text-sm"
|
||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"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"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
import { ArrowLeft, Eraser, Pencil, Plus, Save, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Combobox } from "@/components/ui/combobox"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -142,6 +142,15 @@ export default function NewFreeIssuePage() {
|
|||||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
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) {
|
function selectItem(key: string, itemId: number) {
|
||||||
updateLine(key, { itemId })
|
updateLine(key, { itemId })
|
||||||
}
|
}
|
||||||
@@ -299,18 +308,12 @@ export default function NewFreeIssuePage() {
|
|||||||
<TableRow key={line.key} className="align-middle">
|
<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 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-80">
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectEditingItem(line.key, Number(v))}>
|
<Combobox
|
||||||
<SelectTrigger className="h-9 text-sm">
|
items={itemOptions}
|
||||||
<SelectValue placeholder="Select item" />
|
value={line.itemId || null}
|
||||||
</SelectTrigger>
|
onValueChange={(v) => selectEditingItem(line.key, v ?? 0)}
|
||||||
<SelectContent>
|
placeholder="Search item…"
|
||||||
{items.map((candidate) => (
|
/>
|
||||||
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
|
||||||
{candidate.sku} - {candidate.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
|
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
|
||||||
{baseUomLabel(items, uoms, line.itemId)}
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
@@ -332,9 +335,20 @@ export default function NewFreeIssuePage() {
|
|||||||
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
|
<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">
|
<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>
|
<h2 className="text-sm font-semibold">Free issue lines</h2>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="size-4" /> Add line
|
<Button
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="text-sm">
|
<Table className="text-sm">
|
||||||
@@ -353,18 +367,12 @@ export default function NewFreeIssuePage() {
|
|||||||
<TableRow key={line.key} className="align-middle">
|
<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 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-80">
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
|
<Combobox
|
||||||
<SelectTrigger className="h-9 text-sm">
|
items={itemOptions}
|
||||||
<SelectValue placeholder="Select item" />
|
value={line.itemId || null}
|
||||||
</SelectTrigger>
|
onValueChange={(v) => selectItem(line.key, v ?? 0)}
|
||||||
<SelectContent>
|
placeholder="Search item…"
|
||||||
{items.map((candidate) => (
|
/>
|
||||||
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
|
|
||||||
{candidate.sku} - {candidate.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-44">
|
<TableCell className="px-4 py-2 min-w-44">
|
||||||
{baseUomLabel(items, uoms, line.itemId)}
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import { cn } from "@/lib/utils"
|
|||||||
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { Customer } from "@/types/customers"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||||
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
|
|
||||||
type Line = CreateSalesInvoiceLineRequest & { key: string }
|
type Line = CreateSalesInvoiceLineRequest & { key: string }
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
const [busy, setBusy] = useState<"post" | "cancel" | null>(null)
|
||||||
|
const [payments, setPayments] = useState<SalesInvoicePayment[]>([])
|
||||||
|
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Number.isFinite(invoiceId)) {
|
if (!Number.isFinite(invoiceId)) {
|
||||||
@@ -132,6 +136,26 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
.catch(() => setPostingCheck(null))
|
.catch(() => setPostingCheck(null))
|
||||||
}, [invoice, invoiceId])
|
}, [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 isDraft = invoice?.status === "Draft"
|
||||||
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
|
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])
|
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
|
||||||
@@ -608,18 +632,63 @@ 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">
|
<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>
|
<span>This invoice is {invoice.status.toLowerCase()} and cannot be edited.</span>
|
||||||
{invoice.status === "Posted" ? (
|
{invoice.status === "Posted" ? (
|
||||||
<Link
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
href={`/dashboard/sales/sales-returns/new?invoiceId=${invoice.salesInvoiceId}`}
|
<Link
|
||||||
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"
|
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
|
<Undo2 className="size-4" />
|
||||||
</Link>
|
Return items
|
||||||
|
</Link>
|
||||||
|
<Button size="sm" onClick={() => setPayDialogOpen(true)} disabled={invoice.totals.balanceAmount <= 0}>
|
||||||
|
{invoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<SalesInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FileBarChart, FileText, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
import { FileBarChart, FileText, Lock, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
|
||||||
|
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -23,6 +23,12 @@ const sections = [
|
|||||||
href: "/dashboard/sales/free-issues",
|
href: "/dashboard/sales/free-issues",
|
||||||
icon: PackageX,
|
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",
|
// title: "Reports",
|
||||||
// description: "Sales report catalog and query entry point.",
|
// description: "Sales report catalog and query entry point.",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export default function SalesReturnsListPage() {
|
|||||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-right text-sm">Return Qty</TableHead>
|
<TableHead className="h-12 px-3 text-right text-sm">Return Qty</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -110,6 +111,7 @@ export default function SalesReturnsListPage() {
|
|||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export default function AdjustmentsListPage() {
|
|||||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Reason</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">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -93,6 +94,7 @@ export default function AdjustmentsListPage() {
|
|||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<AdjustmentStatusBadge status={a.status} />
|
<AdjustmentStatusBadge status={a.status} />
|
||||||
</TableCell>
|
</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>
|
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ export default function CountDetailPage() {
|
|||||||
<p className="text-base font-semibold">Posted — variance adjustment #{postResult.adjustmentId} created</p>
|
<p className="text-base font-semibold">Posted — variance adjustment #{postResult.adjustmentId} created</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -8,6 +8,7 @@ import { AlertTriangle, Save } from "lucide-react"
|
|||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { generateVendorCode } from "@/lib/vendor-code"
|
||||||
import { Vendor } from "@/types/master-data"
|
import { Vendor } from "@/types/master-data"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -39,6 +40,22 @@ export default function VendorDetailPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [togglingStatus, setTogglingStatus] = 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() {
|
function load() {
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
vendorsApi
|
vendorsApi
|
||||||
@@ -177,13 +194,13 @@ export default function VendorDetailPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Code</Label>
|
<Label className="text-base">Code (auto-generated)</Label>
|
||||||
<Input value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} className="h-12 text-base" disabled={conflict} />
|
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
|
||||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Name</Label>
|
<Label className="text-base">Name</Label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
<Input value={name} onChange={(e) => handleNameChange(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
import { ApiResult, PagedResponse } from "@/types/common"
|
import { ApiResult, PagedResponse } from "@/types/common"
|
||||||
import {
|
import {
|
||||||
|
CreateSalesInvoicePaymentRequest,
|
||||||
CreateSalesInvoiceRequest,
|
CreateSalesInvoiceRequest,
|
||||||
CreateSalesSlipRequest,
|
CreateSalesSlipRequest,
|
||||||
SalesInvoice,
|
SalesInvoice,
|
||||||
SalesInvoiceStatus,
|
SalesInvoiceStatus,
|
||||||
|
SalesInvoicePayment,
|
||||||
SalesInvoicePostingCheck,
|
SalesInvoicePostingCheck,
|
||||||
SalesInvoiceSummary,
|
SalesInvoiceSummary,
|
||||||
SalesListFilterParams,
|
SalesListFilterParams,
|
||||||
@@ -65,6 +67,16 @@ export const salesApi = {
|
|||||||
return apiRequest<SalesInvoice>(`/sales-invoices/${salesInvoiceId}/cancel`, { method: "POST" })
|
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>> {
|
listSlips(params: ListSalesSlipsParams = {}): Promise<PagedResponse<SalesSlipSummary>> {
|
||||||
return apiRequest<PagedResponse<SalesSlipSummary>>(`/sales-slips${buildQuery(params)}`)
|
return apiRequest<PagedResponse<SalesSlipSummary>>(`/sales-slips${buildQuery(params)}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ export interface PurchaseReturn {
|
|||||||
status: PurchaseReturnStatus
|
status: PurchaseReturnStatus
|
||||||
createdBy: number
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
glJournalNo: string | null
|
||||||
|
glPostedAt: string | null
|
||||||
lines: PurchaseReturnLine[]
|
lines: PurchaseReturnLine[]
|
||||||
ledgerRefs: number[]
|
ledgerRefs: number[]
|
||||||
}
|
}
|
||||||
@@ -233,6 +235,7 @@ export interface PurchaseReturnSummary {
|
|||||||
createdBy: number
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
lineCount: number
|
lineCount: number
|
||||||
|
glJournalNo: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePurchaseReturnLineInput {
|
export interface CreatePurchaseReturnLineInput {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface SalesInvoiceSummary {
|
|||||||
status: SalesInvoiceStatus
|
status: SalesInvoiceStatus
|
||||||
totals: SalesInvoiceTotals
|
totals: SalesInvoiceTotals
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
glJournalNo: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesInvoicePostingIssue {
|
export interface SalesInvoicePostingIssue {
|
||||||
@@ -95,9 +96,28 @@ export interface SalesInvoice extends SalesInvoiceSummary {
|
|||||||
customerSnapshotTaxNo: string | null
|
customerSnapshotTaxNo: string | null
|
||||||
createdBy: number
|
createdBy: number
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
|
glPostedAt: string | null
|
||||||
lines: SalesInvoiceLine[]
|
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 {
|
export interface CreateSalesInvoiceLineRequest {
|
||||||
itemId: number
|
itemId: number
|
||||||
warehouseId: number
|
warehouseId: number
|
||||||
@@ -329,6 +349,8 @@ export interface SalesReturn {
|
|||||||
status: SalesReturnStatus
|
status: SalesReturnStatus
|
||||||
createdBy: number
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
glJournalNo: string | null
|
||||||
|
glPostedAt: string | null
|
||||||
lines: SalesReturnLine[]
|
lines: SalesReturnLine[]
|
||||||
ledgerRefs: number[]
|
ledgerRefs: number[]
|
||||||
}
|
}
|
||||||
@@ -344,6 +366,7 @@ export interface SalesReturnSummary {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
lineCount: number
|
lineCount: number
|
||||||
totalQty: number
|
totalQty: number
|
||||||
|
glJournalNo: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remaining returnable qty for one sales invoice line (invoiced qty minus already-returned). */
|
/** Remaining returnable qty for one sales invoice line (invoiced qty minus already-returned). */
|
||||||
@@ -365,3 +388,79 @@ export interface CreateSalesReturnRequest {
|
|||||||
reasonCodeId: number
|
reasonCodeId: number
|
||||||
lines: CreateSalesReturnLineInput[]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ export interface StockAdjustment {
|
|||||||
status: AdjustmentStatus
|
status: AdjustmentStatus
|
||||||
createdBy: number
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
glJournalNo: string | null
|
||||||
|
glPostedAt: string | null
|
||||||
lines: AdjustmentLine[]
|
lines: AdjustmentLine[]
|
||||||
ledgerRefs: number[]
|
ledgerRefs: number[]
|
||||||
}
|
}
|
||||||
@@ -186,6 +188,7 @@ export interface StockAdjustmentSummary {
|
|||||||
createdBy: number
|
createdBy: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
lineCount: number
|
lineCount: number
|
||||||
|
glJournalNo: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAdjustmentLineInput {
|
export interface CreateAdjustmentLineInput {
|
||||||
@@ -258,6 +261,8 @@ export interface PostCountResponse {
|
|||||||
status: CountStatus
|
status: CountStatus
|
||||||
/** Null when the count had no variance to post. */
|
/** Null when the count had no variance to post. */
|
||||||
adjustmentId: number | null
|
adjustmentId: number | null
|
||||||
|
/** Null when there was no variance, or the variance's value was 0 either way. */
|
||||||
|
glJournalNo: string | null
|
||||||
ledgerRefs: number[]
|
ledgerRefs: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user