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
|
||||
{
|
||||
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]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||
@@ -70,4 +75,23 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
|
||||
|
||||
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
|
||||
[HttpPost("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
|
||||
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Payment history for this invoice, newest first.</summary>
|
||||
[HttpGet("{salesInvoiceId:int}/payments")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> ListPayments(int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _payments.ListAsync(salesInvoiceId, ct));
|
||||
}
|
||||
|
||||
@@ -21,4 +21,7 @@ public static class DocumentTypes
|
||||
public const string SalesSlip = "SSL";
|
||||
public const string BundleSale = "BND";
|
||||
public const string SalesReturn = "SRET";
|
||||
|
||||
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
|
||||
public const string SalesDayEnd = "DEND";
|
||||
}
|
||||
|
||||
@@ -29,5 +29,11 @@ public class BundleSale
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public int ConcurrencyStamp { get; set; }
|
||||
|
||||
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
|
||||
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
|
||||
/// a bundle sale is a cashier document exactly like a sales slip, just priced as a set.</summary>
|
||||
public int? DayEndId { get; set; }
|
||||
public SalesDayEnd? DayEnd { get; set; }
|
||||
|
||||
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||
}
|
||||
|
||||
@@ -28,5 +28,12 @@ public class PurchaseReturn
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
|
||||
/// Reverses the Inventory/Clearing lines a Grn posts — <see cref="PurchaseReturnLine"/> carries no unit
|
||||
/// price/VAT of its own, so it can't reverse a VAT-recoverable line the way a real credit note would.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Journal number of the revenue-recognition GL journal entry posted when this
|
||||
/// invoice is Posted (Debit Accounts Receivable / Credit Sales Revenue + Tax, plus COGS —
|
||||
/// see SalesPostingService.PostInvoiceAsync). Unlike a SalesSlip, an invoice posts its own
|
||||
/// GL entry immediately rather than waiting for Sales Day End, since invoices aren't a
|
||||
/// cashier document and don't go through that close-out.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
@@ -38,4 +46,5 @@ public class SalesInvoice
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
||||
public ICollection<SalesInvoicePayment> Payments { get; set; } = new List<SalesInvoicePayment>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
|
||||
/// Reverses Inventory/COGS only — <see cref="SalesReturnLine"/> carries no unit price, so revenue/tax aren't
|
||||
/// reversed here (they'd need a per-line price this document doesn't capture).</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ public class SalesSlip
|
||||
public decimal PaidAmount { get; set; }
|
||||
public decimal BalanceAmount { get; set; }
|
||||
|
||||
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
|
||||
public int? DayEndId { get; set; }
|
||||
public SalesDayEnd? DayEnd { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -27,5 +27,10 @@ public class StockAdjustment
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
/// <summary>Journal number of the real GL journal entry posted for this adjustment (set on create — it
|
||||
/// auto-posts). Increases and decreases post separately (never netted) so gains/losses stay visible.</summary>
|
||||
public string? GlJournalNo { get; set; }
|
||||
public DateTime? GlPostedAt { get; set; }
|
||||
|
||||
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
|
||||
|
||||
public sealed record PurchaseReturnDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, 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>
|
||||
public sealed record PurchaseReturnSummaryDto(
|
||||
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 ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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,
|
||||
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
|
||||
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(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
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(
|
||||
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(
|
||||
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>
|
||||
public sealed record SalesReturnSummaryDto(
|
||||
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>
|
||||
/// 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(
|
||||
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>
|
||||
public sealed record AdjustmentSummaryDto(
|
||||
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 ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed record CountSummaryDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
// 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.CreatedAt).IsRequired();
|
||||
builder.Property(r => r.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
@@ -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) })
|
||||
builder.Property<decimal>(p).HasPrecision(18, 4);
|
||||
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
builder.Property(x => x.RowVersion).IsRowVersion();
|
||||
@@ -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 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.CreatedAt).IsRequired();
|
||||
builder.Property(r => r.GlJournalNo).HasMaxLength(30);
|
||||
|
||||
builder.HasOne(r => r.Customer).WithMany().HasForeignKey(r => r.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
|
||||
|
||||
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(a => a.CreatedAt).IsRequired();
|
||||
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(a => a.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
@@ -88,8 +88,10 @@ public class ErpDbContext : DbContext
|
||||
// --- Sales (Phase 1) ---
|
||||
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||
public DbSet<SalesInvoicePayment> SalesInvoicePayments => Set<SalesInvoicePayment>();
|
||||
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
||||
public DbSet<SalesDayEnd> SalesDayEnds => Set<SalesDayEnd>();
|
||||
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
|
||||
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
||||
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("DayEndId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("DiscountTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
@@ -462,6 +465,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("DayEndId");
|
||||
|
||||
b.HasIndex("WarehouseId");
|
||||
|
||||
b.ToTable("bundle_sales", (string)null);
|
||||
@@ -1552,6 +1557,33 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("GrnPaymentId")
|
||||
@@ -1601,33 +1633,6 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("HrDocumentTypeId")
|
||||
@@ -3171,6 +3176,13 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ReasonCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -3776,6 +3788,85 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("hr_salary_components", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.Property<int>("SalesDayEndId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesDayEndId"));
|
||||
|
||||
b.Property<int>("BundleCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<DateOnly>("BusinessDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("CashierUserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("ClosedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ClosedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("CostOfGoodsSold")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("DiscountTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("DocNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("GrandTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<int>("SlipCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("Subtotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("TaxTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.HasKey("SalesDayEndId");
|
||||
|
||||
b.HasIndex("ClosedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CashierUserId", "BusinessDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("sales_day_ends", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.Property<int>("SalesInvoiceId")
|
||||
@@ -3813,6 +3904,13 @@ namespace ERPCore.Migrations
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("GrandTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
@@ -3982,85 +4080,53 @@ namespace ERPCore.Migrations
|
||||
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()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnId"));
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesInvoicePaymentId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("CreatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("DocNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<int>("ReasonCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("WarehouseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ReturnId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ReasonCodeId");
|
||||
|
||||
b.HasIndex("WarehouseId");
|
||||
|
||||
b.ToTable("sales_returns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||
{
|
||||
b.Property<int>("ReturnLineId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ReturnLineId"));
|
||||
|
||||
b.Property<int>("ItemId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("Qty")
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(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");
|
||||
|
||||
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");
|
||||
|
||||
b.HasKey("ReturnLineId");
|
||||
b.HasKey("SalesInvoicePaymentId");
|
||||
|
||||
b.HasIndex("ItemId");
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("ReturnId");
|
||||
b.HasIndex("SalesInvoiceId");
|
||||
|
||||
b.HasIndex("SalesInvoiceLineId");
|
||||
|
||||
b.ToTable("sales_return_lines", (string)null);
|
||||
b.ToTable("sales_invoice_payments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
@@ -4085,6 +4151,13 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ReasonCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -4170,6 +4243,9 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("DayEndId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("DiscountTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
@@ -4223,6 +4299,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("DayEndId");
|
||||
|
||||
b.HasIndex("SlipDate");
|
||||
|
||||
b.HasIndex("SlipNo")
|
||||
@@ -4484,6 +4562,13 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("GlJournalNo")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<DateTime?>("GlPostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ReasonCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -5672,6 +5757,11 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
|
||||
.WithMany("BundleSales")
|
||||
.HasForeignKey("DayEndId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
@@ -5684,6 +5774,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("DayEnd");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
@@ -5976,6 +6068,17 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -5995,17 +6098,6 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom")
|
||||
@@ -6579,6 +6671,25 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Uom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("CashierUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "ClosedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClosedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CashierUser");
|
||||
|
||||
b.Navigation("ClosedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -6631,7 +6742,7 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
@@ -6639,57 +6750,15 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReasonCodeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice")
|
||||
.WithMany("Payments")
|
||||
.HasForeignKey("SalesInvoiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("ReasonCode");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("ReturnId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
|
||||
.WithMany()
|
||||
.HasForeignKey("SalesInvoiceLineId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("Return");
|
||||
|
||||
b.Navigation("SalesInvoiceLine");
|
||||
b.Navigation("SalesInvoice");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
@@ -6767,6 +6836,11 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd")
|
||||
.WithMany("Slips")
|
||||
.HasForeignKey("DayEndId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
@@ -6777,6 +6851,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("DayEnd");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
@@ -7259,11 +7335,6 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("WarrantyNumbers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.Navigation("WarrantyNumbers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
|
||||
{
|
||||
b.Navigation("ReorderSettings");
|
||||
@@ -7333,14 +7404,18 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Outputs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b =>
|
||||
{
|
||||
b.Navigation("BundleSales");
|
||||
|
||||
b.Navigation("Slips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
|
||||
@@ -112,11 +112,13 @@ builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
||||
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
||||
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||
builder.Services.AddScoped<ISalesInvoicePaymentService, SalesInvoicePaymentService>();
|
||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
||||
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
|
||||
|
||||
// Stock transactions + reference data (docs/11 §5–6)
|
||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,7 +17,10 @@ namespace ERPCore.Services;
|
||||
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
|
||||
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
|
||||
/// decrease, layer create on an increase) + ledger posting is delegated to
|
||||
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05).
|
||||
/// <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>
|
||||
public sealed class AdjustmentService : IAdjustmentService
|
||||
{
|
||||
@@ -29,11 +33,16 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glGainAccountCode;
|
||||
private readonly string _glLossAccountCode;
|
||||
|
||||
public AdjustmentService(
|
||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
@@ -44,6 +53,45 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
|
||||
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
|
||||
/// <see cref="CountService"/>'s variance posting mirrors this exact logic for the same reason
|
||||
/// (it creates a <see cref="StockAdjustment"/> through the same <see cref="IStockMutator"/> call).</summary>
|
||||
private async Task<string?> PostAdjustmentJournalAsync(
|
||||
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
|
||||
{
|
||||
var gain = refs.Where(r => r.Direction == Direction.In).Sum(r => r.Value);
|
||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||
if (gain <= 0 && loss <= 0) return null;
|
||||
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
|
||||
var lines = new List<GlJournalEntryLineRequest>();
|
||||
if (gain > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
}
|
||||
if (loss > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
}
|
||||
|
||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "ADJUSTMENT",
|
||||
Reference = docNo,
|
||||
Narration = $"Stock adjustment {docNo}",
|
||||
Lines = lines
|
||||
}, ct);
|
||||
return result.JournalNo;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||
@@ -64,7 +112,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(a => new AdjustmentSummaryDto(
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
|
||||
a.CreatedBy, a.CreatedAt, a.Lines.Count))
|
||||
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
|
||||
.ToListAsync(ct);
|
||||
|
||||
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
|
||||
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
|
||||
|
||||
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
||||
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
|
||||
|
||||
return (entity, refs);
|
||||
}, ct);
|
||||
|
||||
@@ -144,6 +196,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
|
||||
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
||||
a.GlJournalNo, a.GlPostedAt,
|
||||
a.Lines.OrderBy(l => l.AdjLineId)
|
||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
||||
ledgerRefs);
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
private readonly IRepository<BundleSaleTemplate> _templates;
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<SalesDayEnd> _dayEnds;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
@@ -30,6 +31,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
public BundleSaleService(
|
||||
IRepository<BundleSale> bundles,
|
||||
IRepository<BundleSaleTemplate> templates,
|
||||
IRepository<SalesDayEnd> dayEnds,
|
||||
IRepository<Customer> customers,
|
||||
IRepository<Item> items,
|
||||
IRepository<Warehouse> warehouses,
|
||||
@@ -42,6 +44,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
{
|
||||
_templates = templates;
|
||||
_bundles = bundles;
|
||||
_dayEnds = dayEnds;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
_warehouses = warehouses;
|
||||
@@ -110,6 +113,15 @@ public sealed class BundleSaleService : IBundleSaleService
|
||||
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
|
||||
// Same guard as SalesSlipService.CreateAsync — a bundle sale is a cashier document
|
||||
// exactly like a sales slip, so it's blocked by the same closed day (docs/14 Sales Day End).
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var alreadyClosed = await _dayEnds.Query().AsNoTracking()
|
||||
.AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct);
|
||||
if (alreadyClosed)
|
||||
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
|
||||
|
||||
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||
var bundle = new BundleSale
|
||||
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,7 +17,9 @@ namespace ERPCore.Services;
|
||||
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
|
||||
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
|
||||
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
|
||||
/// and closes the count — all in one UoW transaction.
|
||||
/// 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>
|
||||
public sealed class CountService : ICountService
|
||||
{
|
||||
@@ -32,11 +35,16 @@ public sealed class CountService : ICountService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glGainAccountCode;
|
||||
private readonly string _glLossAccountCode;
|
||||
|
||||
public CountService(
|
||||
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_counts = counts;
|
||||
_adjustments = adjustments;
|
||||
@@ -48,6 +56,44 @@ public sealed class CountService : ICountService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
|
||||
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Same logic as <c>AdjustmentService</c>'s private method of the same name — see there for why
|
||||
/// gains/losses post as separate lines instead of a net.</summary>
|
||||
private async Task<string?> PostAdjustmentJournalAsync(
|
||||
string docNo, DateTime now, IReadOnlyList<StockLedger> refs, CancellationToken ct)
|
||||
{
|
||||
var gain = refs.Where(r => r.Direction == Direction.In).Sum(r => r.Value);
|
||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||
if (gain <= 0 && loss <= 0) return null;
|
||||
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
|
||||
var lines = new List<GlJournalEntryLineRequest>();
|
||||
if (gain > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
}
|
||||
if (loss > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
}
|
||||
|
||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "ADJUSTMENT",
|
||||
Reference = docNo,
|
||||
Narration = $"Stock adjustment {docNo}",
|
||||
Lines = lines
|
||||
}, ct);
|
||||
return result.JournalNo;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||
@@ -160,7 +206,7 @@ public sealed class CountService : ICountService
|
||||
{
|
||||
count.Status = CountStatus.Posted;
|
||||
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()
|
||||
@@ -168,7 +214,7 @@ public sealed class CountService : ICountService
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var (adjustmentId, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
var (adjustmentId, glJournalNo, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
||||
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);
|
||||
|
||||
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
||||
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
|
||||
|
||||
count.Status = CountStatus.Posted;
|
||||
return (adjustment.AdjustmentId, refs);
|
||||
return (adjustment.AdjustmentId, adjustment.GlJournalNo, refs);
|
||||
}, ct);
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -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>
|
||||
/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19).
|
||||
///
|
||||
/// Deliberately posts no GL journal entry anywhere in this file, for the same reason
|
||||
/// as <see cref="TransferService"/>: every movement here — raw material consumed
|
||||
/// (<see cref="StartStageAsync"/>), finished goods received (<see cref="PostReceiptAsync"/>),
|
||||
/// leftovers/cancellation returned to stock — stays inside the single shared Inventory
|
||||
/// GL account (raw materials, WIP, and finished goods are not separate GL accounts in
|
||||
/// this chart of accounts). And unlike a real write-off, scrap here is never removed
|
||||
/// from the cost pool — <see cref="PostReceiptAsync"/> divides the full consumed value
|
||||
/// by the GOOD quantity only, so a scrapped unit's cost is absorbed into the surviving
|
||||
/// units' cost rather than expensed. So there is no value entering, leaving, or being
|
||||
/// destroyed anywhere in a run — nothing for a journal entry to say.
|
||||
/// </summary>
|
||||
public sealed class ProductionRunService : IProductionRunService
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,7 +17,9 @@ namespace ERPCore.Services;
|
||||
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
|
||||
/// code and generates an outbound stock movement via the shared
|
||||
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
|
||||
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction.
|
||||
/// 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>
|
||||
public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
{
|
||||
@@ -31,12 +34,15 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public PurchaseReturnService(
|
||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
||||
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
|
||||
ICurrentUser currentUser, IUnitOfWork uow)
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_returns = returns;
|
||||
_vendors = vendors;
|
||||
@@ -49,6 +55,9 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
|
||||
@@ -69,7 +78,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(r => new PurchaseReturnSummaryDto(
|
||||
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
|
||||
r.CreatedBy, r.CreatedAt, r.Lines.Count))
|
||||
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.GlJournalNo))
|
||||
.ToListAsync(ct);
|
||||
|
||||
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
|
||||
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.PurchaseReturn, ret.ReturnId, now, deltas, token);
|
||||
|
||||
var totalValue = refs.Sum(r => r.Value);
|
||||
if (totalValue > 0)
|
||||
{
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "PURCHASE-RETURN",
|
||||
Reference = docNo,
|
||||
Narration = $"Purchase return {docNo} — stock returned to vendor",
|
||||
Lines = new List<GlJournalEntryLineRequest>
|
||||
{
|
||||
new(_glClearingAccountCode, totalValue, 0m, $"Purchase return {docNo}"),
|
||||
new(_glInventoryAccountCode, 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
|
||||
}
|
||||
}, token);
|
||||
ret.GlJournalNo = glResult.JournalNo;
|
||||
ret.GlPostedAt = now;
|
||||
}
|
||||
|
||||
return (ret, refs);
|
||||
}, ct);
|
||||
|
||||
@@ -155,6 +186,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
|
||||
private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList<int> ledgerRefs) => new(
|
||||
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
|
||||
r.GlJournalNo, r.GlPostedAt,
|
||||
r.Lines.OrderBy(l => l.ReturnLineId)
|
||||
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
|
||||
ledgerRefs);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
var customerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
|
||||
var lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
|
||||
|
||||
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var 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);
|
||||
}
|
||||
|
||||
@@ -190,5 +198,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
||||
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt);
|
||||
x.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.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
|
||||
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
|
||||
invoice.GlJournalNo, invoice.GlPostedAt,
|
||||
MapInvoiceTotals(invoice),
|
||||
invoice.Lines.Select(l => new SalesInvoiceLineDto(
|
||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||
|
||||
@@ -5,6 +5,7 @@ using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
@@ -22,6 +23,13 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glAccountsReceivableCode;
|
||||
private readonly string _glSalesRevenueAccountCode;
|
||||
private readonly string _glSalesDiscountAccountCode;
|
||||
private readonly string _glTaxPayableAccountCode;
|
||||
private readonly string _glCogsAccountCode;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
|
||||
public SalesPostingService(
|
||||
IRepository<SalesInvoice> invoices,
|
||||
@@ -31,7 +39,9 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
IUnitOfWork uow,
|
||||
IGeneralLedgerService gl,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_slips = slips;
|
||||
@@ -41,6 +51,15 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
_sales = sales;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
|
||||
// Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice
|
||||
// just recognizes them immediately on Post instead of waiting for the day's close.
|
||||
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
|
||||
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
|
||||
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
|
||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
@@ -143,7 +162,51 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.SalesInvoice,
|
||||
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)
|
||||
=> PostAsync(
|
||||
@@ -181,7 +244,11 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
Action<T> setUpdated,
|
||||
string sourceDocType,
|
||||
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
|
||||
{
|
||||
var doc = await load() ?? throw new NotFoundException(notFoundMessage);
|
||||
@@ -192,6 +259,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var totalCogs = 0m;
|
||||
foreach (var line in getLines(doc))
|
||||
{
|
||||
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.
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
totalCogs += cost * line.Qty;
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
|
||||
}
|
||||
|
||||
setPosted(doc);
|
||||
setUpdated(doc);
|
||||
|
||||
if (afterConsumption is not null) await afterConsumption(doc, totalCogs, token);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -17,7 +18,10 @@ namespace ERPCore.Services;
|
||||
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
|
||||
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW
|
||||
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
|
||||
/// reversed.
|
||||
/// 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>
|
||||
public sealed class SalesReturnService : ISalesReturnService
|
||||
{
|
||||
@@ -33,12 +37,16 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glCogsAccountCode;
|
||||
|
||||
public SalesReturnService(
|
||||
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
|
||||
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_returns = returns;
|
||||
_customers = customers;
|
||||
@@ -52,6 +60,9 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
||||
@@ -72,7 +83,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(r => new SalesReturnSummaryDto(
|
||||
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status,
|
||||
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.Lines.Sum(l => l.Qty)))
|
||||
r.CreatedBy, r.CreatedAt, r.Lines.Count, r.Lines.Sum(l => l.Qty), r.GlJournalNo))
|
||||
.ToListAsync(ct);
|
||||
|
||||
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
|
||||
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
|
||||
|
||||
var totalValue = refs.Sum(r => r.Value);
|
||||
if (totalValue > 0)
|
||||
{
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "SALES-RETURN",
|
||||
Reference = docNo,
|
||||
Narration = $"Sales return {docNo} — stock restocked",
|
||||
Lines = new List<GlJournalEntryLineRequest>
|
||||
{
|
||||
new(_glInventoryAccountCode, totalValue, 0m, $"Sales return {docNo}"),
|
||||
new(_glCogsAccountCode, 0m, totalValue, $"Sales return {docNo} — COGS reversal")
|
||||
}
|
||||
}, token);
|
||||
ret.GlJournalNo = glResult.JournalNo;
|
||||
ret.GlPostedAt = now;
|
||||
}
|
||||
|
||||
return (ret, refs);
|
||||
}, ct);
|
||||
|
||||
@@ -194,6 +227,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
|
||||
private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new(
|
||||
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
|
||||
r.GlJournalNo, r.GlPostedAt,
|
||||
r.Lines.OrderBy(l => l.ReturnLineId)
|
||||
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
|
||||
ledgerRefs);
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace ERPCore.Services;
|
||||
public sealed class SalesSlipService : ISalesSlipService
|
||||
{
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<SalesDayEnd> _dayEnds;
|
||||
private readonly IRepository<Customer> _customers;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
@@ -31,12 +32,13 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
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,
|
||||
ISalesDocumentWorkflowService workflow,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||
{
|
||||
_slips = slips;
|
||||
_dayEnds = dayEnds;
|
||||
_customers = customers;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
@@ -107,22 +109,37 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
{
|
||||
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct);
|
||||
|
||||
var slip = new SalesSlip
|
||||
{
|
||||
SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, ct),
|
||||
SlipDate = DateTime.UtcNow,
|
||||
CustomerId = request.CustomerId,
|
||||
WarehouseId = request.WarehouseId,
|
||||
CashierUserId = request.CashierUserId,
|
||||
Status = SalesSlipStatus.Draft,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
|
||||
Recalculate(slip);
|
||||
// A cashier can't ring up more sales for a business date they've already closed
|
||||
// (SalesDayEndService.CloseAsync) — otherwise the new slip would sit outside every
|
||||
// day-end's totals and GL posting forever (docs/14 Sales Day End).
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var alreadyClosed = await _dayEnds.Query().AsNoTracking()
|
||||
.AnyAsync(x => x.CashierUserId == request.CashierUserId && x.BusinessDate == today, ct);
|
||||
if (alreadyClosed)
|
||||
throw new ConflictException($"Cashier {request.CashierUserId} already closed today's ({today:yyyy-MM-dd}) sales — day-end has been posted.");
|
||||
|
||||
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||
var lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
|
||||
|
||||
var slip = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ namespace ERPCore.Services;
|
||||
/// layers (row-locked; negative-stock blocked) and records the value-weighted cost
|
||||
/// on the line; receive recreates the destination layer at that cost
|
||||
/// (cost-preserving — no revaluation). Both run in a single UoW transaction.
|
||||
///
|
||||
/// Deliberately posts no GL journal entry: <see cref="Warehouse"/> carries no GL
|
||||
/// account of its own (unlike a GL "location"/cost-center dimension), so every
|
||||
/// warehouse's stock sits in the same Inventory account — a transfer would debit and
|
||||
/// credit that identical account for the identical amount, a no-op journal entry that
|
||||
/// exists only to say nothing. The per-warehouse movement is still fully recorded in
|
||||
/// <see cref="StockLedger"/>/<see cref="StockLayer"/>, which is what On-Hand-by-warehouse
|
||||
/// reporting actually reads from.
|
||||
/// </summary>
|
||||
public sealed class TransferService : ITransferService
|
||||
{
|
||||
|
||||
@@ -77,4 +77,9 @@ public static class ErrorCodes
|
||||
public const string GrnNotPayable = "GRN_NOT_PAYABLE";
|
||||
public const string GrnPaymentExceedsBalance = "GRN_PAYMENT_EXCEEDS_BALANCE";
|
||||
public const string GrnBankAccountNotFound = "GRN_BANK_ACCOUNT_NOT_FOUND";
|
||||
|
||||
// Sales invoice payments
|
||||
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";
|
||||
public const string SalesInvoicePaymentExceedsBalance = "SALES_INVOICE_PAYMENT_EXCEEDS_BALANCE";
|
||||
public const string SalesInvoiceBankAccountNotFound = "SALES_INVOICE_BANK_ACCOUNT_NOT_FOUND";
|
||||
}
|
||||
|
||||
@@ -31,5 +31,21 @@
|
||||
"GlVatRecoverableAccountCode": "1200",
|
||||
"GlClearingAccountCode": "2000"
|
||||
},
|
||||
"SalesDayEnd": {
|
||||
"GlCashAccountCode": "1000-01",
|
||||
"GlSalesRevenueAccountCode": "4000",
|
||||
"GlSalesDiscountAccountCode": "4100",
|
||||
"GlTaxPayableAccountCode": "2100",
|
||||
"GlCogsAccountCode": "5000",
|
||||
"GlInventoryAccountCode": "1100"
|
||||
},
|
||||
"Adjustment": {
|
||||
"GlInventoryAccountCode": "1100",
|
||||
"GlGainAccountCode": "4200",
|
||||
"GlLossAccountCode": "5100"
|
||||
},
|
||||
"SalesInvoice": {
|
||||
"GlAccountsReceivableCode": "1300"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user