intigrate others sales , return and implement day end duntinalities
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user