From 0750773f94367e7a114a359705977523fbdb063d Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 4 Aug 2026 17:19:30 +0530 Subject: [PATCH] centralize sales flow --- Backend/ERPCore/Program.cs | 4 + Backend/ERPCore/Services/BundleSaleService.cs | 95 +++----- .../ISalesDocumentWorkflowService.cs | 9 + .../Interfaces/ISalesDomainService.cs | 51 +++++ .../Interfaces/ISalesMappingService.cs | 12 + .../Interfaces/ISalesPostingService.cs | 14 ++ .../Services/SalesDocumentWorkflowService.cs | 44 ++++ .../ERPCore/Services/SalesDomainService.cs | 110 +++++++++ .../ERPCore/Services/SalesInvoiceService.cs | 163 +++---------- .../ERPCore/Services/SalesMappingService.cs | 51 +++++ .../ERPCore/Services/SalesPostingService.cs | 214 ++++++++++++++++++ Backend/ERPCore/Services/SalesSlipService.cs | 163 +++---------- .../erp-system/app/dashboard/sales/page.tsx | 14 +- .../components/Layouts/AppSidebar.tsx | 4 +- docs/SALES_MODULE_PLAN.md | 56 +++++ 15 files changed, 675 insertions(+), 329 deletions(-) create mode 100644 Backend/ERPCore/Services/Interfaces/ISalesDocumentWorkflowService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISalesMappingService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISalesPostingService.cs create mode 100644 Backend/ERPCore/Services/SalesDocumentWorkflowService.cs create mode 100644 Backend/ERPCore/Services/SalesDomainService.cs create mode 100644 Backend/ERPCore/Services/SalesMappingService.cs create mode 100644 Backend/ERPCore/Services/SalesPostingService.cs diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index ca937fb..cc51f3e 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -106,6 +106,10 @@ builder.Services.AddScoped(); // Sales (Phase 1) builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/BundleSaleService.cs b/Backend/ERPCore/Services/BundleSaleService.cs index 1532844..072958e 100644 --- a/Backend/ERPCore/Services/BundleSaleService.cs +++ b/Backend/ERPCore/Services/BundleSaleService.cs @@ -22,8 +22,8 @@ public sealed class BundleSaleService : IBundleSaleService private readonly IRepository _uoms; private readonly IRepository _warehouses; private readonly IRepository _users; - private readonly ISalesPricingService _pricing; - private readonly IFifoCostingService _fifo; + private readonly ISalesDomainService _sales; + private readonly ISalesPostingService _posting; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; private readonly IUnitOfWork _uow; @@ -36,8 +36,8 @@ public sealed class BundleSaleService : IBundleSaleService IRepository uoms, IRepository warehouses, IRepository users, - ISalesPricingService pricing, - IFifoCostingService fifo, + ISalesDomainService sales, + ISalesPostingService posting, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) @@ -49,8 +49,8 @@ public sealed class BundleSaleService : IBundleSaleService _uoms = uoms; _warehouses = warehouses; _users = users; - _pricing = pricing; - _fifo = fifo; + _sales = sales; + _posting = posting; _currentUser = currentUser; _numbers = numbers; _uow = uow; @@ -106,27 +106,12 @@ public sealed class BundleSaleService : IBundleSaleService return bundle is null ? null : Map(bundle); } - public async Task CheckPostingAsync(int bundleSaleId, CancellationToken ct = default) - { - var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) - ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); - if (bundle.Status != BundleSaleStatus.Draft) - return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty()); - - var issues = new List(); - foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle)) - { - var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); - if (available >= line.Qty) continue; - var item = await _items.Query().AsNoTracking().Where(x => x.ItemId == line.ItemId).Select(x => new { x.Sku, x.Name }).FirstAsync(ct); - issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available)); - } - return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues); - } + public Task CheckPostingAsync(int bundleSaleId, CancellationToken ct = default) + => _posting.CheckBundleAsync(bundleSaleId, ct); public async Task CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default) { - await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, ct); + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); var template = await _templates.Query().AsNoTracking().Include(x => x.Lines) .FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct); var bundle = new BundleSale @@ -143,7 +128,7 @@ public sealed class BundleSaleService : IBundleSaleService CreatedAt = DateTime.UtcNow }; bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); - bundle.Lines = await BuildLinesAsync(template, request.Lines, ct); + bundle.Lines = await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct); Recalculate(bundle, request.BundlePrice); bundle.BundleCode = $"{bundle.BundleNo}-B"; await _bundles.AddAsync(bundle, ct); @@ -159,7 +144,7 @@ public sealed class BundleSaleService : IBundleSaleService if (bundle.Status != BundleSaleStatus.Draft) throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited."); - await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, ct); + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); var template = await _templates.Query().AsNoTracking().Include(x => x.Lines) .FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct); bundle.CustomerId = request.CustomerId; @@ -169,7 +154,7 @@ public sealed class BundleSaleService : IBundleSaleService bundle.BundleName = request.BundleName; bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); bundle.Lines.Clear(); - foreach (var line in await BuildLinesAsync(template, request.Lines, ct)) bundle.Lines.Add(line); + foreach (var line in await BuildLinesAsync(template, request.WarehouseId, request.Lines, ct)) bundle.Lines.Add(line); Recalculate(bundle, request.BundlePrice); bundle.UpdatedAt = DateTime.UtcNow; bundle.ConcurrencyStamp++; @@ -178,32 +163,12 @@ public sealed class BundleSaleService : IBundleSaleService } public async Task PostAsync(int bundleSaleId, CancellationToken ct = default) - { - var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) - ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); - if (bundle.Status != BundleSaleStatus.Draft) - throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be posted."); - - var check = await CheckPostingAsync(bundleSaleId, ct); - if (!check.CanPost) - throw new ConflictException("Resolve stock shortages before posting this bundle sale."); - - var posted = await _uow.ExecuteInTransactionAsync(async token => { - foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle)) - { - 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); - await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId, - Direction.Out, line.Qty, cost, 0m, nameof(BundleSale), bundle.BundleSaleId, DateTime.UtcNow, token); - } - bundle.Status = BundleSaleStatus.Posted; - bundle.UpdatedAt = DateTime.UtcNow; - bundle.ConcurrencyStamp++; - return bundle; - }, ct); - return Map(posted); - } + await _posting.PostBundleAsync(bundleSaleId, ct); + var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.BundleSaleId == bundleSaleId, ct); + return Map(bundle); + } public async Task CancelAsync(int bundleSaleId, CancellationToken ct = default) { @@ -218,19 +183,8 @@ public sealed class BundleSaleService : IBundleSaleService return Map(bundle); } - private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, int templateId, CancellationToken ct) - { - if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) - throw new NotFoundException($"Customer {customerId} was not found."); - if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct)) - throw new NotFoundException($"Warehouse {warehouseId} was not found."); - if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct)) - throw new NotFoundException($"User {cashierUserId} was not found."); - if (!await _templates.Query().AnyAsync(x => x.BundleSaleTemplateId == templateId, ct)) - throw new NotFoundException($"Bundle template {templateId} was not found."); - } - - private async Task> BuildLinesAsync(BundleSaleTemplate template, IReadOnlyList requestLines, CancellationToken ct) + private async Task> BuildLinesAsync( + BundleSaleTemplate template, int warehouseId, IReadOnlyList requestLines, CancellationToken ct) { var lines = new List(); var sourceLines = requestLines.Count > 0 @@ -248,8 +202,15 @@ public sealed class BundleSaleService : IBundleSaleService foreach (var r in sourceLines) { + if (r.Qty <= 0) + throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422); + if (r.WarehouseId != warehouseId) + throw new DomainException(ErrorCodes.Validation, + $"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422); var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); - var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct); + await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct); + var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct); + var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false); lines.Add(new BundleSaleLine { ItemId = r.ItemId, @@ -258,7 +219,7 @@ public sealed class BundleSaleService : IBundleSaleService UomId = r.UomId, WarehouseId = r.WarehouseId, UnitPrice = resolved.UnitPrice, - LineTotal = r.Qty * resolved.UnitPrice, + LineTotal = calc.LineTotal, IncludeInBundle = r.IncludeInBundle, IsComponent = true, ParentLineId = null diff --git a/Backend/ERPCore/Services/Interfaces/ISalesDocumentWorkflowService.cs b/Backend/ERPCore/Services/Interfaces/ISalesDocumentWorkflowService.cs new file mode 100644 index 0000000..fc3a7b3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesDocumentWorkflowService.cs @@ -0,0 +1,9 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesDocumentWorkflowService +{ + Task LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default); + Task LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs new file mode 100644 index 0000000..99372be --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs @@ -0,0 +1,51 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesDomainService +{ + Task ValidateSalesHeaderAsync( + int customerId, + int warehouseId, + int? cashierUserId, + bool requireCashierUser, + CancellationToken ct = default); + + Task ValidateSalesLineAsync( + int headerWarehouseId, + int lineItemId, + int lineUomId, + int lineWarehouseId, + decimal qty, + decimal freeQty, + int? parentLineId, + CancellationToken ct = default); + + Task ResolveLinePriceAsync( + int itemId, + int warehouseId, + decimal? requestedUnitPrice, + bool allowManualOverride, + CancellationToken ct = default); + + SalesLineComputation ComputeLine( + decimal qty, + decimal freeQty, + decimal unitPrice, + SalesDiscountMode discountMode, + decimal discountPct, + decimal discountValue, + decimal discountAmount, + decimal taxPct, + bool isFreeIssue); + + Task IsStockedItemAsync(int itemId, CancellationToken ct = default); +} + +public sealed record SalesLineComputation( + decimal Gross, + decimal DiscountTotal, + decimal NetUnitPrice, + decimal LineTotal, + decimal TaxAmount); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesMappingService.cs b/Backend/ERPCore/Services/Interfaces/ISalesMappingService.cs new file mode 100644 index 0000000..6c09cf2 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesMappingService.cs @@ -0,0 +1,12 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesMappingService +{ + SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice); + SalesSlipTotalsDto MapSlipTotals(SalesSlip slip); + SalesInvoiceDto MapInvoice(SalesInvoice invoice); + SalesSlipDto MapSlip(SalesSlip slip); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesPostingService.cs b/Backend/ERPCore/Services/Interfaces/ISalesPostingService.cs new file mode 100644 index 0000000..4c9cfa3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesPostingService.cs @@ -0,0 +1,14 @@ +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesPostingService +{ + Task CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default); + Task CheckSlipAsync(int salesSlipId, CancellationToken ct = default); + Task CheckBundleAsync(int bundleSaleId, CancellationToken ct = default); + + Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default); + Task PostSlipAsync(int salesSlipId, CancellationToken ct = default); + Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs new file mode 100644 index 0000000..e3a8780 --- /dev/null +++ b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs @@ -0,0 +1,44 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService +{ + private readonly IRepository _invoices; + private readonly IRepository _slips; + + public SalesDocumentWorkflowService(IRepository invoices, IRepository slips) + { + _invoices = invoices; + _slips = slips; + } + + public async Task LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default) + { + var invoice = await _invoices.Query().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + if (invoice.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412); + if (invoice.Status != SalesInvoiceStatus.Draft) + throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited."); + return invoice; + } + + public async Task LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited."); + return slip; + } +} diff --git a/Backend/ERPCore/Services/SalesDomainService.cs b/Backend/ERPCore/Services/SalesDomainService.cs new file mode 100644 index 0000000..8fb1a93 --- /dev/null +++ b/Backend/ERPCore/Services/SalesDomainService.cs @@ -0,0 +1,110 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesDomainService : ISalesDomainService +{ + private readonly IRepository _customers; + private readonly IRepository _warehouses; + private readonly IRepository _users; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly ISalesPricingService _pricing; + + public SalesDomainService( + IRepository customers, + IRepository warehouses, + IRepository users, + IRepository items, + IRepository uoms, + ISalesPricingService pricing) + { + _customers = customers; + _warehouses = warehouses; + _users = users; + _items = items; + _uoms = uoms; + _pricing = pricing; + } + + public async Task ValidateSalesHeaderAsync(int customerId, int warehouseId, int? cashierUserId, bool requireCashierUser, CancellationToken ct = default) + { + if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) + throw new NotFoundException($"Customer {customerId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct)) + throw new NotFoundException($"Warehouse {warehouseId} was not found."); + if (requireCashierUser) + { + if (cashierUserId is null) + throw new DomainException(ErrorCodes.Validation, "Cashier user is required.", 422); + if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct)) + throw new NotFoundException($"User {cashierUserId} was not found."); + } + } + + public async Task ValidateSalesLineAsync( + int headerWarehouseId, int lineItemId, int lineUomId, int lineWarehouseId, decimal qty, decimal freeQty, int? parentLineId, CancellationToken ct = default) + { + if (qty <= 0) + throw new DomainException(ErrorCodes.Validation, "Sales line quantity must be greater than zero.", 422); + if (freeQty < 0) + throw new DomainException(ErrorCodes.Validation, "Sales free quantity cannot be negative.", 422); + if (parentLineId is not null && parentLineId <= 0) + throw new DomainException(ErrorCodes.Validation, "Parent line id must be positive when supplied.", 422); + if (lineWarehouseId != headerWarehouseId) + throw new DomainException(ErrorCodes.Validation, $"Sales line warehouse {lineWarehouseId} must match header warehouse {headerWarehouseId}.", 422); + if (!await _items.Query().AnyAsync(x => x.ItemId == lineItemId, ct)) + throw new NotFoundException($"Item {lineItemId} was not found."); + if (!await _uoms.Query().AnyAsync(x => x.UomId == lineUomId, ct)) + throw new NotFoundException($"UOM {lineUomId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == lineWarehouseId, ct)) + throw new NotFoundException($"Warehouse {lineWarehouseId} was not found."); + } + + public Task ResolveLinePriceAsync(int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default) + => _pricing.ResolveAsync(itemId, warehouseId, requestedUnitPrice, allowManualOverride, ct); + + public SalesLineComputation ComputeLine( + decimal qty, + decimal freeQty, + decimal unitPrice, + SalesDiscountMode discountMode, + decimal discountPct, + decimal discountValue, + decimal discountAmount, + decimal taxPct, + bool isFreeIssue) + { + var gross = qty * unitPrice; + var discountTotal = isFreeIssue + ? 0m + : CalculateDiscount(gross, discountMode, discountPct, discountValue, discountAmount); + var netUnit = qty > 0 ? (gross - discountTotal) / qty : 0m; + var lineTotal = gross - discountTotal; + var taxAmount = lineTotal * (taxPct / 100m); + return new SalesLineComputation(gross, discountTotal, netUnit, lineTotal, taxAmount); + } + + public async Task IsStockedItemAsync(int itemId, CancellationToken ct = default) + => await _items.Query().AsNoTracking() + .Where(x => x.ItemId == itemId) + .Select(x => x.StockNature == StockNature.Stocked) + .FirstAsync(ct); + + private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) + { + var computed = mode == SalesDiscountMode.Amount + ? discountValue + : gross * (discountPct / 100m); + + if (computed <= 0m && legacyDiscountAmount > 0m) + computed = legacyDiscountAmount; + + return Math.Min(gross, Math.Max(0m, computed)); + } +} diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 4052aae..61d9e0e 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -21,15 +21,18 @@ public sealed class SalesInvoiceService : ISalesInvoiceService private readonly IRepository _items; private readonly IRepository _uoms; private readonly IRepository _warehouses; - private readonly ISalesPricingService _pricing; - private readonly IFifoCostingService _fifo; + private readonly ISalesDomainService _sales; + private readonly ISalesPostingService _posting; + private readonly ISalesMappingService _mapping; + private readonly ISalesDocumentWorkflowService _workflow; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; private readonly IUnitOfWork _uow; public SalesInvoiceService( IRepository invoices, IRepository customers, IRepository items, - IRepository uoms, IRepository warehouses, ISalesPricingService pricing, IFifoCostingService fifo, + IRepository uoms, IRepository warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping, + ISalesDocumentWorkflowService workflow, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) { _invoices = invoices; @@ -37,8 +40,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService _items = items; _uoms = uoms; _warehouses = warehouses; - _pricing = pricing; - _fifo = fifo; + _sales = sales; + _posting = posting; + _mapping = mapping; + _workflow = workflow; _currentUser = currentUser; _numbers = numbers; _uow = uow; @@ -66,47 +71,15 @@ public sealed class SalesInvoiceService : ISalesInvoiceService { var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); - return invoice is null ? null : new ETagged(Map(invoice), invoice.RowVersion); + return invoice is null ? null : new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); } - public async Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default) - { - var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) - .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) - ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); - - if (invoice.Status != SalesInvoiceStatus.Draft) - return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty()); - - var issues = new List(); - foreach (var line in invoice.Lines) - { - var requestedQty = line.Qty + line.FreeQty; - var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); - if (available >= requestedQty) continue; - - var item = await _items.Query().AsNoTracking() - .Where(x => x.ItemId == line.ItemId) - .Select(x => new { x.Sku, x.Name }) - .FirstAsync(ct); - issues.Add(new SalesInvoicePostingIssueDto( - line.SalesInvoiceLineId, - line.ItemId, - item.Sku, - item.Name, - line.WarehouseId, - requestedQty, - available, - requestedQty - available, - line.IsFreeIssue)); - } - - return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues); - } + public Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default) + => _posting.CheckInvoiceAsync(salesInvoiceId, ct); public async Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default) { - await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct); + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct); var invoice = new SalesInvoice { InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct), @@ -120,62 +93,39 @@ public sealed class SalesInvoiceService : ISalesInvoiceService }; 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.Lines, ct); + invoice.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct); Recalculate(invoice); await _invoices.AddAsync(invoice, ct); await _uow.SaveChangesAsync(ct); - return new ETagged(Map(invoice), invoice.RowVersion); + return new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); } public async Task> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default) { - var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) - ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); - if (invoice.RowVersion != expectedRowVersion) - throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412); - if (invoice.Status != SalesInvoiceStatus.Draft) - throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited."); + var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, ct); - await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct); + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct); invoice.CustomerId = request.CustomerId; invoice.WarehouseId = request.WarehouseId; invoice.InvoiceType = request.InvoiceType; 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.Clear(); - foreach (var line in await BuildLinesAsync(request.Lines, ct)) invoice.Lines.Add(line); + foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) invoice.Lines.Add(line); Recalculate(invoice); invoice.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); - return new ETagged(Map(invoice), invoice.RowVersion); + return new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); } public async Task PostAsync(int salesInvoiceId, CancellationToken ct = default) - { - var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) - ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); - if (invoice.Status != SalesInvoiceStatus.Draft) - throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be posted."); - - var posted = await _uow.ExecuteInTransactionAsync(async token => { - foreach (var line in invoice.Lines) - { - if (line.Qty <= 0) continue; - var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token); - var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); - await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId, - Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesInvoice), invoice.SalesInvoiceId, DateTime.UtcNow, token); - } - - invoice.Status = SalesInvoiceStatus.Posted; - invoice.UpdatedAt = DateTime.UtcNow; - return invoice; - }, ct); - - return Map(posted); - } + await _posting.PostInvoiceAsync(salesInvoiceId, ct); + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); + return _mapping.MapInvoice(invoice); + } public async Task CancelAsync(int salesInvoiceId, CancellationToken ct = default) { @@ -186,43 +136,21 @@ public sealed class SalesInvoiceService : ISalesInvoiceService invoice.Status = SalesInvoiceStatus.Cancelled; invoice.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); - return Map(invoice); + return _mapping.MapInvoice(invoice); } - private async Task ValidateReferencesAsync(int customerId, int warehouseId, List lines, CancellationToken ct) - { - if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) - throw new NotFoundException($"Customer {customerId} was not found."); - if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct)) - throw new NotFoundException($"Warehouse {warehouseId} was not found."); - foreach (var line in lines) - { - if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct)) - throw new NotFoundException($"Item {line.ItemId} was not found."); - if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct)) - throw new NotFoundException($"UOM {line.UomId} was not found."); - if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct)) - throw new NotFoundException($"Warehouse {line.WarehouseId} was not found."); - } - } - - private async Task> BuildLinesAsync(List requests, CancellationToken ct) + private async Task> BuildLinesAsync(int headerWarehouseId, List requests, CancellationToken ct) { var lines = new List(); foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); - var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; - var gross = r.Qty * unitPrice; - var discountTotal = r.IsFreeIssue - ? 0m - : CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount); - var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m; - var lineTotal = gross - discountTotal; - var taxAmount = lineTotal * (r.TaxPct / 100m); + var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue); lines.Add(new SalesInvoiceLine { @@ -236,12 +164,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService BaseCost = unitPrice, PriceSource = priceSource, DiscountPct = r.DiscountPct, - DiscountAmount = discountTotal, + DiscountAmount = calc.DiscountTotal, DiscountMode = r.DiscountMode, - NetUnitPrice = netUnit, - LineTotal = lineTotal, + NetUnitPrice = calc.NetUnitPrice, + LineTotal = calc.LineTotal, TaxPct = r.TaxPct, - TaxAmount = taxAmount, + TaxAmount = calc.TaxAmount, IsFreeIssue = r.IsFreeIssue, ParentLineId = r.ParentLineId }); @@ -261,26 +189,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService invoice.BalanceAmount = invoice.NetPayable; } - private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) - { - var computed = mode == SalesDiscountMode.Amount - ? discountValue - : gross * (discountPct / 100m); - - if (computed <= 0m && legacyDiscountAmount > 0m) - computed = legacyDiscountAmount; - - return Math.Min(gross, Math.Max(0m, computed)); - } - - private static SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( + private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, - x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto( - x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), x.CreatedAt); - - private static SalesInvoiceDto Map(SalesInvoice x) => new( - x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.CustomerSnapshotTaxNo, - x.WarehouseId, x.InvoiceType, x.Status, x.CreatedBy, x.CreatedAt, x.UpdatedAt, - new SalesInvoiceTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), - x.Lines.Select(l => new SalesInvoiceLineDto(l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt); } diff --git a/Backend/ERPCore/Services/SalesMappingService.cs b/Backend/ERPCore/Services/SalesMappingService.cs new file mode 100644 index 0000000..4d60780 --- /dev/null +++ b/Backend/ERPCore/Services/SalesMappingService.cs @@ -0,0 +1,51 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services; + +public sealed class SalesMappingService : ISalesMappingService +{ + public SalesInvoiceTotalsDto MapInvoiceTotals(SalesInvoice invoice) + => new( + invoice.Subtotal, + invoice.DiscountTotal, + invoice.Lines.Sum(l => l.FreeQty), + invoice.TaxTotal, + invoice.GrandTotal, + invoice.RoundOff, + invoice.NetPayable, + invoice.PaidAmount, + invoice.BalanceAmount); + + public SalesSlipTotalsDto MapSlipTotals(SalesSlip slip) + => new( + slip.Subtotal, + slip.DiscountTotal, + slip.Lines.Sum(l => l.FreeQty), + slip.TaxTotal, + slip.GrandTotal, + slip.PaidAmount, + slip.BalanceAmount); + + public SalesInvoiceDto MapInvoice(SalesInvoice invoice) + => new( + invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId, + invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId, + invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt, + MapInvoiceTotals(invoice), + invoice.Lines.Select(l => new SalesInvoiceLineDto( + l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, + l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, + l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + + public SalesSlipDto MapSlip(SalesSlip slip) + => new( + slip.SalesSlipId, slip.SlipNo, slip.SlipDate, slip.CustomerId, slip.CustomerSnapshotName, + slip.WarehouseId, slip.CashierUserId, slip.Status, slip.CreatedAt, slip.UpdatedAt, + MapSlipTotals(slip), + slip.Lines.Select(l => new SalesSlipLineDto( + l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, + l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, + l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); +} diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs new file mode 100644 index 0000000..556ab58 --- /dev/null +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -0,0 +1,214 @@ +using ERPCore.Domain; +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.Interfaces; +using ERPCore.Services.Stock; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesPostingService : ISalesPostingService +{ + private readonly IRepository _invoices; + private readonly IRepository _slips; + private readonly IRepository _bundles; + private readonly IRepository _items; + private readonly IFifoCostingService _fifo; + private readonly ISalesDomainService _sales; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public SalesPostingService( + IRepository invoices, + IRepository slips, + IRepository bundles, + IRepository items, + IFifoCostingService fifo, + ISalesDomainService sales, + ICurrentUser currentUser, + IUnitOfWork uow) + { + _invoices = invoices; + _slips = slips; + _bundles = bundles; + _items = items; + _fifo = fifo; + _sales = sales; + _currentUser = currentUser; + _uow = uow; + } + + public async Task CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) + { + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + + if (invoice.Status != SalesInvoiceStatus.Draft) + return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, false, Array.Empty()); + + var issues = new List(); + foreach (var line in invoice.Lines) + { + if (!await _sales.IsStockedItemAsync(line.ItemId, ct)) + continue; + var requestedQty = line.Qty + line.FreeQty; + var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); + if (available >= requestedQty) continue; + + var item = await _items.Query().AsNoTracking() + .Where(x => x.ItemId == line.ItemId) + .Select(x => new { x.Sku, x.Name }) + .FirstAsync(ct); + + issues.Add(new SalesInvoicePostingIssueDto( + line.SalesInvoiceLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, + requestedQty, available, requestedQty - available, line.IsFreeIssue)); + } + + return new SalesInvoicePostingCheckDto(invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.Status, issues.Count == 0, issues); + } + + public async Task CheckSlipAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + + if (slip.Status != SalesSlipStatus.Draft) + return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty()); + + var issues = new List(); + foreach (var line in slip.Lines) + { + if (!await _sales.IsStockedItemAsync(line.ItemId, ct)) + continue; + var requestedQty = line.Qty + line.FreeQty; + var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); + if (available >= requestedQty) continue; + + var item = await _items.Query().AsNoTracking() + .Where(x => x.ItemId == line.ItemId) + .Select(x => new { x.Sku, x.Name }) + .FirstAsync(ct); + + issues.Add(new SalesSlipPostingIssueDto( + line.SalesSlipLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, + requestedQty, available, requestedQty - available, line.IsFreeIssue)); + } + + return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues); + } + + public async Task CheckBundleAsync(int bundleSaleId, CancellationToken ct = default) + { + var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct) + ?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found."); + + if (bundle.Status != BundleSaleStatus.Draft) + return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty()); + + var issues = new List(); + foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle)) + { + if (!await _sales.IsStockedItemAsync(line.ItemId, ct)) + continue; + var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); + if (available >= line.Qty) continue; + + var item = await _items.Query().AsNoTracking() + .Where(x => x.ItemId == line.ItemId) + .Select(x => new { x.Sku, x.Name }) + .FirstAsync(ct); + + issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available)); + } + + return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues); + } + + public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) + => PostAsync( + load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct), + notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.", + statusSelector: x => x.Status, + ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.", + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + setPosted: x => x.Status = SalesInvoiceStatus.Posted, + setUpdated: x => x.UpdatedAt = DateTime.UtcNow, + sourceDocType: nameof(SalesInvoice), + getDocId: x => x.SalesInvoiceId, + ct: ct); + + public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default) + => PostAsync( + load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct), + notFoundMessage: $"Sales slip {salesSlipId} was not found.", + statusSelector: x => x.Status, + ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.", + getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)), + setPosted: x => x.Status = SalesSlipStatus.Posted, + setUpdated: x => x.UpdatedAt = DateTime.UtcNow, + sourceDocType: nameof(SalesSlip), + getDocId: x => x.SalesSlipId, + ct: ct); + + public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default) + => PostAsync( + load: () => _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct), + notFoundMessage: $"Bundle sale {bundleSaleId} was not found.", + statusSelector: x => x.Status, + ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.", + getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)), + setPosted: x => x.Status = BundleSaleStatus.Posted, + setUpdated: x => x.UpdatedAt = DateTime.UtcNow, + sourceDocType: nameof(BundleSale), + getDocId: x => x.BundleSaleId, + ct: ct); + + private async Task PostAsync( + Func> load, + string notFoundMessage, + Func statusSelector, + Func ensureDraftMessage, + Func> getLines, + Action setPosted, + Action setUpdated, + string sourceDocType, + Func getDocId, + CancellationToken ct) + where T : class + { + var doc = await load() ?? throw new NotFoundException(notFoundMessage); + var status = statusSelector(doc); + var statusValue = status?.ToString() ?? string.Empty; + if (!string.Equals(statusValue, "Draft", StringComparison.Ordinal)) + throw new ConflictException(ensureDraftMessage(doc)); + + await _uow.ExecuteInTransactionAsync(async token => + { + foreach (var line in getLines(doc)) + { + if (line.Qty <= 0) continue; + if (!await _sales.IsStockedItemAsync(line.ItemId, token)) + continue; + + 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); + 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); + }, ct); + } + + private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty); +} diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs index 3d79bdb..9e8074d 100644 --- a/Backend/ERPCore/Services/SalesSlipService.cs +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -22,15 +22,18 @@ public sealed class SalesSlipService : ISalesSlipService private readonly IRepository _uoms; private readonly IRepository _warehouses; private readonly IRepository _users; - private readonly ISalesPricingService _pricing; - private readonly IFifoCostingService _fifo; + private readonly ISalesDomainService _sales; + private readonly ISalesPostingService _posting; + private readonly ISalesMappingService _mapping; + private readonly ISalesDocumentWorkflowService _workflow; private readonly ICurrentUser _currentUser; private readonly INumberSequenceService _numbers; private readonly IUnitOfWork _uow; public SalesSlipService( IRepository slips, IRepository customers, IRepository items, - IRepository uoms, IRepository warehouses, IRepository users, ISalesPricingService pricing, IFifoCostingService fifo, + IRepository uoms, IRepository warehouses, IRepository users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping, + ISalesDocumentWorkflowService workflow, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) { _slips = slips; @@ -39,8 +42,10 @@ public sealed class SalesSlipService : ISalesSlipService _uoms = uoms; _warehouses = warehouses; _users = users; - _pricing = pricing; - _fifo = fifo; + _sales = sales; + _posting = posting; + _mapping = mapping; + _workflow = workflow; _currentUser = currentUser; _numbers = numbers; _uow = uow; @@ -67,7 +72,7 @@ public sealed class SalesSlipService : ISalesSlipService { var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); - return slip is null ? null : new ETagged(Map(slip), slip.RowVersion); + return slip is null ? null : new ETagged(_mapping.MapSlip(slip), slip.RowVersion); } public async Task> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) @@ -95,44 +100,12 @@ public sealed class SalesSlipService : ISalesSlipService return slip is null ? null : new ETagged(MapFreeIssue(slip), slip.RowVersion); } - public async Task CheckPostingAsync(int salesSlipId, CancellationToken ct = default) - { - var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) - .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) - ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); - - if (slip.Status != SalesSlipStatus.Draft) - return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, false, Array.Empty()); - - var issues = new List(); - foreach (var line in slip.Lines) - { - var requestedQty = line.Qty + line.FreeQty; - var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct); - if (available >= requestedQty) continue; - - var item = await _items.Query().AsNoTracking() - .Where(x => x.ItemId == line.ItemId) - .Select(x => new { x.Sku, x.Name }) - .FirstAsync(ct); - issues.Add(new SalesSlipPostingIssueDto( - line.SalesSlipLineId, - line.ItemId, - item.Sku, - item.Name, - line.WarehouseId, - requestedQty, - available, - requestedQty - available, - line.IsFreeIssue)); - } - - return new SalesSlipPostingCheckDto(slip.SalesSlipId, slip.SlipNo, slip.Status, issues.Count == 0, issues); - } + public Task CheckPostingAsync(int salesSlipId, CancellationToken ct = default) + => _posting.CheckSlipAsync(salesSlipId, ct); public async Task> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default) { - await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct); + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); var slip = new SalesSlip { @@ -145,61 +118,38 @@ public sealed class SalesSlipService : ISalesSlipService 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.Lines, ct); + slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct); Recalculate(slip); await _slips.AddAsync(slip, ct); await _uow.SaveChangesAsync(ct); - return new ETagged(Map(slip), slip.RowVersion); + return new ETagged(_mapping.MapSlip(slip), slip.RowVersion); } public async Task> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default) { - var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) - ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); - if (slip.RowVersion != expectedRowVersion) - throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412); - if (slip.Status != SalesSlipStatus.Draft) - throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited."); + var slip = await _workflow.LoadEditableSlipAsync(salesSlipId, expectedRowVersion, ct); - await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct); + await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, true, ct); slip.CustomerId = request.CustomerId; slip.WarehouseId = request.WarehouseId; slip.CashierUserId = request.CashierUserId; slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); slip.Lines.Clear(); - foreach (var line in await BuildLinesAsync(request.Lines, ct)) slip.Lines.Add(line); + foreach (var line in await BuildLinesAsync(request.WarehouseId, request.Lines, ct)) slip.Lines.Add(line); Recalculate(slip); slip.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); - return new ETagged(Map(slip), slip.RowVersion); + return new ETagged(_mapping.MapSlip(slip), slip.RowVersion); } public async Task PostAsync(int salesSlipId, CancellationToken ct = default) - { - var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) - ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); - if (slip.Status != SalesSlipStatus.Draft) - throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be posted."); - - var posted = await _uow.ExecuteInTransactionAsync(async token => { - foreach (var line in slip.Lines) - { - if (line.Qty <= 0 && line.FreeQty <= 0) continue; - var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token); - var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); - await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId, - Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesSlip), slip.SalesSlipId, DateTime.UtcNow, token); - } - - slip.Status = SalesSlipStatus.Posted; - slip.UpdatedAt = DateTime.UtcNow; - return slip; - }, ct); - - return Map(posted); - } + await _posting.PostSlipAsync(salesSlipId, ct); + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + .FirstAsync(x => x.SalesSlipId == salesSlipId, ct); + return _mapping.MapSlip(slip); + } public async Task CancelAsync(int salesSlipId, CancellationToken ct = default) { @@ -210,45 +160,21 @@ public sealed class SalesSlipService : ISalesSlipService slip.Status = SalesSlipStatus.Cancelled; slip.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); - return Map(slip); + return _mapping.MapSlip(slip); } - private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, List lines, CancellationToken ct) - { - if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) - throw new NotFoundException($"Customer {customerId} was not found."); - if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct)) - throw new NotFoundException($"Warehouse {warehouseId} was not found."); - if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct)) - throw new NotFoundException($"User {cashierUserId} was not found."); - foreach (var line in lines) - { - if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct)) - throw new NotFoundException($"Item {line.ItemId} was not found."); - if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct)) - throw new NotFoundException($"UOM {line.UomId} was not found."); - if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct)) - throw new NotFoundException($"Warehouse {line.WarehouseId} was not found."); - } - } - - private async Task> BuildLinesAsync(List requests, CancellationToken ct) + private async Task> BuildLinesAsync(int headerWarehouseId, List requests, CancellationToken ct) { var lines = new List(); foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); - var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; - var gross = r.Qty * unitPrice; - var discountTotal = r.IsFreeIssue - ? 0m - : CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount); - var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m; - var lineTotal = gross - discountTotal; - var taxAmount = lineTotal * (r.TaxPct / 100m); + var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue); lines.Add(new SalesSlipLine { @@ -263,11 +189,11 @@ public sealed class SalesSlipService : ISalesSlipService PriceSource = priceSource, DiscountMode = r.DiscountMode, DiscountPct = r.DiscountPct, - DiscountAmount = discountTotal, - NetUnitPrice = netUnit, - LineTotal = lineTotal, + DiscountAmount = calc.DiscountTotal, + NetUnitPrice = calc.NetUnitPrice, + LineTotal = calc.LineTotal, TaxPct = r.TaxPct, - TaxAmount = taxAmount, + TaxAmount = calc.TaxAmount, IsFreeIssue = r.IsFreeIssue, ParentLineId = r.ParentLineId }); @@ -285,21 +211,9 @@ public sealed class SalesSlipService : ISalesSlipService slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount; } - private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) - { - var computed = mode == SalesDiscountMode.Amount - ? discountValue - : gross * (discountPct / 100m); - - if (computed <= 0m && legacyDiscountAmount > 0m) - computed = legacyDiscountAmount; - - return Math.Min(gross, Math.Max(0m, computed)); - } - - private static SalesSlipSummaryDto MapSummary(SalesSlip x) => new( + private SalesSlipSummaryDto MapSummary(SalesSlip x) => new( x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status, - new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt); + _mapping.MapSlipTotals(x), x.CreatedAt); private FreeIssueSummaryDto MapFreeIssueSummary(SalesSlip x) { @@ -352,8 +266,5 @@ public sealed class SalesSlipService : ISalesSlipService x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); } - private static SalesSlipDto Map(SalesSlip x) => new( - x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.Status, x.CreatedAt, x.UpdatedAt, - new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), - x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.UomId, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x); } diff --git a/Frontend/erp-system/app/dashboard/sales/page.tsx b/Frontend/erp-system/app/dashboard/sales/page.tsx index 5d9fcbd..ad9c562 100644 --- a/Frontend/erp-system/app/dashboard/sales/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/page.tsx @@ -23,12 +23,12 @@ const sections = [ href: "/dashboard/sales/free-issues", icon: PackageX, }, - { - title: "Reports", - description: "Sales report catalog and query entry point.", - href: "/dashboard/sales/reports", - icon: FileBarChart, - }, + // { + // title: "Reports", + // description: "Sales report catalog and query entry point.", + // href: "/dashboard/sales/reports", + // icon: FileBarChart, + // }, ] export default function SalesHubPage() { @@ -42,7 +42,7 @@ export default function SalesHubPage() {

Sales

- Invoices, slips, free issues, and reporting in one place. + Invoices, slips, and free issues in one place.

diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 5585562..4d86d3d 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -112,7 +112,7 @@ const navItems: { { title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart }, { title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes }, { title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX }, - { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, + // { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart }, ], }, { title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, @@ -143,7 +143,7 @@ const navItems: { { title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck }, { title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock }, { title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote }, - { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart }, + // { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart }, { title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal }, ], }, diff --git a/docs/SALES_MODULE_PLAN.md b/docs/SALES_MODULE_PLAN.md index 861e318..c37dc9e 100644 --- a/docs/SALES_MODULE_PLAN.md +++ b/docs/SALES_MODULE_PLAN.md @@ -15,6 +15,26 @@ The design stays aligned with the existing backend patterns: Returns, credit notes, and sales returns are **out of scope for Phase 1**. +### Current Implementation Status +The Phase 1 core is implemented and wired across the backend and frontend for: +- sales invoices +- sales slips +- free issues as a slip alias +- bundle sales +- sales posting to stock/FIFO + +Shared backend services now centralize the repeated sales logic: +- sales validation and pricing +- sales posting checks and FIFO outbound posting +- invoice/slip mapping and totals +- shared draft edit/load workflow checks + +Still intentionally separate: +- bundle pricing and bundle margin behavior +- production and GRN as upstream stock/cost sources +- reservation/backorder flow +- sales reports visibility in the frontend UI, which is currently hidden from the navigation but still implemented in the backend + --- ## Phase 1 - Basic Standard Sales Module @@ -42,6 +62,22 @@ Implement the minimum sales flow needed for both B2B and B2C: - Stock posting - Basic sales reports +### Implemented Shared Services +- `ISalesDomainService` + - header validation + - line validation + - price resolution + - line financial computation + - stock-item classification +- `ISalesPostingService` + - invoice/slip/bundle posting checks + - shared FIFO posting for stocked items +- `ISalesMappingService` + - invoice/slip totals mapping + - invoice/slip DTO mapping +- `ISalesDocumentWorkflowService` + - shared editable-document load and concurrency checks for invoice/slip draft updates + ### Not in Scope for Phase 1 - customer groups - price lists @@ -51,6 +87,7 @@ Implement the minimum sales flow needed for both B2B and B2C: - approval workflow - returns and credit notes - advanced customer segmentation +- fully unified sales provenance tracing across production, GRN, and sales documents ### Phase 1 Entity Design @@ -211,6 +248,20 @@ When an invoice or slip is posted: - maintain source document traceability - update totals in the same transaction +Sales document provenance is stored by document family: +- `SalesInvoice` / `SalesInvoiceLine` +- `SalesSlip` / `SalesSlipLine` +- `BundleSale` / `BundleSaleLine` + +Inventory movement provenance is stored in: +- `StockLayer` +- `StockLedger` via `SourceDocType` / `SourceDocId` +- `JournalEntryStub` via `SourceDocType` / `SourceDocId` + +Upstream cost/availability sources remain: +- `Grn` / `GrnLine` for inbound purchasing cost +- `ProductionRun` and stage tables for finished-goods production cost + ### Phase 1 API Route List - `GET /api/v1/customers` - `GET /api/v1/customers/{id}` @@ -239,6 +290,10 @@ When an invoice or slip is posted: - `GET /api/v1/reports/sales/{reportId}` - `POST /api/v1/reports/sales/query` +Note: +- the sales report backend routes remain implemented +- the frontend report entry points are currently hidden from navigation, but the screens and API contracts still exist + ### Phase 1 Folder / Module Plan - `Domain/Entities` - add `Customer`, `SalesInvoice`, `SalesInvoiceLine`, `SalesSlip`, `SalesSlipLine` @@ -389,6 +444,7 @@ Allocation of a payment across invoices. - Verify discounts calculate correctly by percentage and fixed value. - Verify free issue lines post stock and appear in reports. - Verify stock ledger entries are created once per posted document. +- Verify the frontend sales hub and sidebar only expose invoice, slip, and free-issue entry points while report pages remain reachable directly. - Verify Phase 1 routes remain stable before Phase 2 is added. ## Assumptions