Merge pull request 'centralize sales flow' (#26) from fix-sales-module into Dev
Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -106,6 +106,10 @@ builder.Services.AddScoped<IGrnService, GrnService>();
|
|||||||
|
|
||||||
// Sales (Phase 1)
|
// Sales (Phase 1)
|
||||||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||||
|
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
|
||||||
|
builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
||||||
|
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
||||||
|
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
||||||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<User> _users;
|
private readonly IRepository<User> _users;
|
||||||
private readonly ISalesPricingService _pricing;
|
private readonly ISalesDomainService _sales;
|
||||||
private readonly IFifoCostingService _fifo;
|
private readonly ISalesPostingService _posting;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
@@ -36,8 +36,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
IRepository<Uom> uoms,
|
IRepository<Uom> uoms,
|
||||||
IRepository<Warehouse> warehouses,
|
IRepository<Warehouse> warehouses,
|
||||||
IRepository<User> users,
|
IRepository<User> users,
|
||||||
ISalesPricingService pricing,
|
ISalesDomainService sales,
|
||||||
IFifoCostingService fifo,
|
ISalesPostingService posting,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
INumberSequenceService numbers,
|
INumberSequenceService numbers,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
@@ -49,8 +49,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
_uoms = uoms;
|
_uoms = uoms;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_users = users;
|
_users = users;
|
||||||
_pricing = pricing;
|
_sales = sales;
|
||||||
_fifo = fifo;
|
_posting = posting;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
@@ -106,27 +106,12 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
return bundle is null ? null : Map(bundle);
|
return bundle is null ? null : Map(bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
|
public Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
|
||||||
{
|
=> _posting.CheckBundleAsync(bundleSaleId, ct);
|
||||||
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<BundleSalePostingIssueDto>());
|
|
||||||
|
|
||||||
var issues = new List<BundleSalePostingIssueDto>();
|
|
||||||
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 async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
public async Task<BundleSaleDto> 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)
|
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||||
var bundle = new BundleSale
|
var bundle = new BundleSale
|
||||||
@@ -143,7 +128,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
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);
|
Recalculate(bundle, request.BundlePrice);
|
||||||
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
||||||
await _bundles.AddAsync(bundle, ct);
|
await _bundles.AddAsync(bundle, ct);
|
||||||
@@ -159,7 +144,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
if (bundle.Status != BundleSaleStatus.Draft)
|
if (bundle.Status != BundleSaleStatus.Draft)
|
||||||
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
|
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)
|
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||||
bundle.CustomerId = request.CustomerId;
|
bundle.CustomerId = request.CustomerId;
|
||||||
@@ -169,7 +154,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
bundle.BundleName = request.BundleName;
|
bundle.BundleName = request.BundleName;
|
||||||
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
bundle.Lines.Clear();
|
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);
|
Recalculate(bundle, request.BundlePrice);
|
||||||
bundle.UpdatedAt = DateTime.UtcNow;
|
bundle.UpdatedAt = DateTime.UtcNow;
|
||||||
bundle.ConcurrencyStamp++;
|
bundle.ConcurrencyStamp++;
|
||||||
@@ -178,32 +163,12 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
|
public async Task<BundleSaleDto> 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))
|
await _posting.PostBundleAsync(bundleSaleId, ct);
|
||||||
{
|
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
|
.FirstAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
||||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
return Map(bundle);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
|
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -218,19 +183,8 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
return Map(bundle);
|
return Map(bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, int templateId, CancellationToken ct)
|
private async Task<List<BundleSaleLine>> BuildLinesAsync(
|
||||||
{
|
BundleSaleTemplate template, int warehouseId, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, 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<List<BundleSaleLine>> BuildLinesAsync(BundleSaleTemplate template, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var lines = new List<BundleSaleLine>();
|
var lines = new List<BundleSaleLine>();
|
||||||
var sourceLines = requestLines.Count > 0
|
var sourceLines = requestLines.Count > 0
|
||||||
@@ -248,8 +202,15 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
|
|
||||||
foreach (var r in sourceLines)
|
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 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
|
lines.Add(new BundleSaleLine
|
||||||
{
|
{
|
||||||
ItemId = r.ItemId,
|
ItemId = r.ItemId,
|
||||||
@@ -258,7 +219,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
UomId = r.UomId,
|
UomId = r.UomId,
|
||||||
WarehouseId = r.WarehouseId,
|
WarehouseId = r.WarehouseId,
|
||||||
UnitPrice = resolved.UnitPrice,
|
UnitPrice = resolved.UnitPrice,
|
||||||
LineTotal = r.Qty * resolved.UnitPrice,
|
LineTotal = calc.LineTotal,
|
||||||
IncludeInBundle = r.IncludeInBundle,
|
IncludeInBundle = r.IncludeInBundle,
|
||||||
IsComponent = true,
|
IsComponent = true,
|
||||||
ParentLineId = null
|
ParentLineId = null
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ISalesDocumentWorkflowService
|
||||||
|
{
|
||||||
|
Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -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<SalesPriceResolution> 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<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SalesLineComputation(
|
||||||
|
decimal Gross,
|
||||||
|
decimal DiscountTotal,
|
||||||
|
decimal NetUnitPrice,
|
||||||
|
decimal LineTotal,
|
||||||
|
decimal TaxAmount);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ISalesPostingService
|
||||||
|
{
|
||||||
|
Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
|
Task<SalesSlipPostingCheckDto> CheckSlipAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
Task<BundleSalePostingCheckDto> 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);
|
||||||
|
}
|
||||||
@@ -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<SalesInvoice> _invoices;
|
||||||
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
|
||||||
|
public SalesDocumentWorkflowService(IRepository<SalesInvoice> invoices, IRepository<SalesSlip> slips)
|
||||||
|
{
|
||||||
|
_invoices = invoices;
|
||||||
|
_slips = slips;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesInvoice> 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<SalesSlip> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Customer> _customers;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<Uom> _uoms;
|
||||||
|
private readonly ISalesPricingService _pricing;
|
||||||
|
|
||||||
|
public SalesDomainService(
|
||||||
|
IRepository<Customer> customers,
|
||||||
|
IRepository<Warehouse> warehouses,
|
||||||
|
IRepository<User> users,
|
||||||
|
IRepository<Item> items,
|
||||||
|
IRepository<Uom> 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<SalesPriceResolution> 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<bool> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,15 +21,18 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly ISalesPricingService _pricing;
|
private readonly ISalesDomainService _sales;
|
||||||
private readonly IFifoCostingService _fifo;
|
private readonly ISalesPostingService _posting;
|
||||||
|
private readonly ISalesMappingService _mapping;
|
||||||
|
private readonly ISalesDocumentWorkflowService _workflow;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public SalesInvoiceService(
|
public SalesInvoiceService(
|
||||||
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
||||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesPricingService pricing, IFifoCostingService fifo,
|
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||||
|
ISalesDocumentWorkflowService workflow,
|
||||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
@@ -37,8 +40,10 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
_items = items;
|
_items = items;
|
||||||
_uoms = uoms;
|
_uoms = uoms;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_pricing = pricing;
|
_sales = sales;
|
||||||
_fifo = fifo;
|
_posting = posting;
|
||||||
|
_mapping = mapping;
|
||||||
|
_workflow = workflow;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
@@ -66,47 +71,15 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
{
|
{
|
||||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
|
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
|
||||||
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
return invoice is null ? null : new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
|
public Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
{
|
=> _posting.CheckInvoiceAsync(salesInvoiceId, ct);
|
||||||
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<SalesInvoicePostingIssueDto>());
|
|
||||||
|
|
||||||
var issues = new List<SalesInvoicePostingIssueDto>();
|
|
||||||
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 async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
public async Task<ETagged<SalesInvoiceDto>> 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
|
var invoice = new SalesInvoice
|
||||||
{
|
{
|
||||||
InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct),
|
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.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.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);
|
Recalculate(invoice);
|
||||||
|
|
||||||
await _invoices.AddAsync(invoice, ct);
|
await _invoices.AddAsync(invoice, ct);
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
public async Task<ETagged<SalesInvoiceDto>> 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)
|
var invoice = await _workflow.LoadEditableInvoiceAsync(salesInvoiceId, expectedRowVersion, 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.");
|
|
||||||
|
|
||||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
|
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
|
||||||
invoice.CustomerId = request.CustomerId;
|
invoice.CustomerId = request.CustomerId;
|
||||||
invoice.WarehouseId = request.WarehouseId;
|
invoice.WarehouseId = request.WarehouseId;
|
||||||
invoice.InvoiceType = request.InvoiceType;
|
invoice.InvoiceType = request.InvoiceType;
|
||||||
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
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.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
|
||||||
invoice.Lines.Clear();
|
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);
|
Recalculate(invoice);
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
return new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default)
|
public async Task<SalesInvoiceDto> 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)
|
await _posting.PostInvoiceAsync(salesInvoiceId, ct);
|
||||||
{
|
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
if (line.Qty <= 0) continue;
|
.FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
|
||||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token);
|
return _mapping.MapInvoice(invoice);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default)
|
public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -186,43 +136,21 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
invoice.Status = SalesInvoiceStatus.Cancelled;
|
invoice.Status = SalesInvoiceStatus.Cancelled;
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return Map(invoice);
|
return _mapping.MapInvoice(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateReferencesAsync(int customerId, int warehouseId, List<CreateSalesInvoiceLineRequest> lines, CancellationToken ct)
|
private async Task<List<SalesInvoiceLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesInvoiceLineRequest> requests, 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<List<SalesInvoiceLine>> BuildLinesAsync(List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var lines = new List<SalesInvoiceLine>();
|
var lines = new List<SalesInvoiceLine>();
|
||||||
foreach (var r in requests)
|
foreach (var r in requests)
|
||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
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 unitPrice = resolved.UnitPrice;
|
||||||
var priceSource = resolved.PriceSource;
|
var priceSource = resolved.PriceSource;
|
||||||
|
|
||||||
var gross = r.Qty * unitPrice;
|
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||||
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);
|
|
||||||
|
|
||||||
lines.Add(new SalesInvoiceLine
|
lines.Add(new SalesInvoiceLine
|
||||||
{
|
{
|
||||||
@@ -236,12 +164,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
BaseCost = unitPrice,
|
BaseCost = unitPrice,
|
||||||
PriceSource = priceSource,
|
PriceSource = priceSource,
|
||||||
DiscountPct = r.DiscountPct,
|
DiscountPct = r.DiscountPct,
|
||||||
DiscountAmount = discountTotal,
|
DiscountAmount = calc.DiscountTotal,
|
||||||
DiscountMode = r.DiscountMode,
|
DiscountMode = r.DiscountMode,
|
||||||
NetUnitPrice = netUnit,
|
NetUnitPrice = calc.NetUnitPrice,
|
||||||
LineTotal = lineTotal,
|
LineTotal = calc.LineTotal,
|
||||||
TaxPct = r.TaxPct,
|
TaxPct = r.TaxPct,
|
||||||
TaxAmount = taxAmount,
|
TaxAmount = calc.TaxAmount,
|
||||||
IsFreeIssue = r.IsFreeIssue,
|
IsFreeIssue = r.IsFreeIssue,
|
||||||
ParentLineId = r.ParentLineId
|
ParentLineId = r.ParentLineId
|
||||||
});
|
});
|
||||||
@@ -261,26 +189,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
invoice.BalanceAmount = invoice.NetPayable;
|
invoice.BalanceAmount = invoice.NetPayable;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||||
{
|
|
||||||
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(
|
|
||||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
||||||
x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto(
|
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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<SalesInvoice> _invoices;
|
||||||
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
private readonly IRepository<BundleSale> _bundles;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
|
private readonly ISalesDomainService _sales;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public SalesPostingService(
|
||||||
|
IRepository<SalesInvoice> invoices,
|
||||||
|
IRepository<SalesSlip> slips,
|
||||||
|
IRepository<BundleSale> bundles,
|
||||||
|
IRepository<Item> 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<SalesInvoicePostingCheckDto> 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<SalesInvoicePostingIssueDto>());
|
||||||
|
|
||||||
|
var issues = new List<SalesInvoicePostingIssueDto>();
|
||||||
|
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<SalesSlipPostingCheckDto> 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<SalesSlipPostingIssueDto>());
|
||||||
|
|
||||||
|
var issues = new List<SalesSlipPostingIssueDto>();
|
||||||
|
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<BundleSalePostingCheckDto> 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<BundleSalePostingIssueDto>());
|
||||||
|
|
||||||
|
var issues = new List<BundleSalePostingIssueDto>();
|
||||||
|
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<T>(
|
||||||
|
Func<Task<T?>> load,
|
||||||
|
string notFoundMessage,
|
||||||
|
Func<T, object> statusSelector,
|
||||||
|
Func<T, string> ensureDraftMessage,
|
||||||
|
Func<T, IEnumerable<PostingLine>> getLines,
|
||||||
|
Action<T> setPosted,
|
||||||
|
Action<T> setUpdated,
|
||||||
|
string sourceDocType,
|
||||||
|
Func<T, int> 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);
|
||||||
|
}
|
||||||
@@ -22,15 +22,18 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<User> _users;
|
private readonly IRepository<User> _users;
|
||||||
private readonly ISalesPricingService _pricing;
|
private readonly ISalesDomainService _sales;
|
||||||
private readonly IFifoCostingService _fifo;
|
private readonly ISalesPostingService _posting;
|
||||||
|
private readonly ISalesMappingService _mapping;
|
||||||
|
private readonly ISalesDocumentWorkflowService _workflow;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public SalesSlipService(
|
public SalesSlipService(
|
||||||
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
||||||
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesPricingService pricing, IFifoCostingService fifo,
|
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> users, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
|
||||||
|
ISalesDocumentWorkflowService workflow,
|
||||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_slips = slips;
|
_slips = slips;
|
||||||
@@ -39,8 +42,10 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
_uoms = uoms;
|
_uoms = uoms;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_users = users;
|
_users = users;
|
||||||
_pricing = pricing;
|
_sales = sales;
|
||||||
_fifo = fifo;
|
_posting = posting;
|
||||||
|
_mapping = mapping;
|
||||||
|
_workflow = workflow;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
@@ -67,7 +72,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
{
|
{
|
||||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||||
return slip is null ? null : new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
return slip is null ? null : new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
public async Task<PagedResponse<FreeIssueSummaryDto>> 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<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
|
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
|
public Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default)
|
||||||
{
|
=> _posting.CheckSlipAsync(salesSlipId, ct);
|
||||||
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<SalesSlipPostingIssueDto>());
|
|
||||||
|
|
||||||
var issues = new List<SalesSlipPostingIssueDto>();
|
|
||||||
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 async Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default)
|
public async Task<ETagged<SalesSlipDto>> 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
|
var slip = new SalesSlip
|
||||||
{
|
{
|
||||||
@@ -145,61 +118,38 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
slip.Lines = await BuildLinesAsync(request.Lines, ct);
|
slip.Lines = await BuildLinesAsync(request.WarehouseId, request.Lines, ct);
|
||||||
Recalculate(slip);
|
Recalculate(slip);
|
||||||
|
|
||||||
await _slips.AddAsync(slip, ct);
|
await _slips.AddAsync(slip, ct);
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
public async Task<ETagged<SalesSlipDto>> 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)
|
var slip = await _workflow.LoadEditableSlipAsync(salesSlipId, expectedRowVersion, 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.");
|
|
||||||
|
|
||||||
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.CustomerId = request.CustomerId;
|
||||||
slip.WarehouseId = request.WarehouseId;
|
slip.WarehouseId = request.WarehouseId;
|
||||||
slip.CashierUserId = request.CashierUserId;
|
slip.CashierUserId = request.CashierUserId;
|
||||||
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
slip.Lines.Clear();
|
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);
|
Recalculate(slip);
|
||||||
slip.UpdatedAt = DateTime.UtcNow;
|
slip.UpdatedAt = DateTime.UtcNow;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
return new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default)
|
public async Task<SalesSlipDto> 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)
|
await _posting.PostSlipAsync(salesSlipId, ct);
|
||||||
{
|
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
if (line.Qty <= 0 && line.FreeQty <= 0) continue;
|
.FirstAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token);
|
return _mapping.MapSlip(slip);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default)
|
public async Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -210,45 +160,21 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
slip.Status = SalesSlipStatus.Cancelled;
|
slip.Status = SalesSlipStatus.Cancelled;
|
||||||
slip.UpdatedAt = DateTime.UtcNow;
|
slip.UpdatedAt = DateTime.UtcNow;
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
return Map(slip);
|
return _mapping.MapSlip(slip);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, List<CreateSalesSlipLineRequest> lines, CancellationToken ct)
|
private async Task<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, 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<List<SalesSlipLine>> BuildLinesAsync(List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var lines = new List<SalesSlipLine>();
|
var lines = new List<SalesSlipLine>();
|
||||||
foreach (var r in requests)
|
foreach (var r in requests)
|
||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
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 unitPrice = resolved.UnitPrice;
|
||||||
var priceSource = resolved.PriceSource;
|
var priceSource = resolved.PriceSource;
|
||||||
|
|
||||||
var gross = r.Qty * unitPrice;
|
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, r.TaxPct, r.IsFreeIssue);
|
||||||
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);
|
|
||||||
|
|
||||||
lines.Add(new SalesSlipLine
|
lines.Add(new SalesSlipLine
|
||||||
{
|
{
|
||||||
@@ -263,11 +189,11 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
PriceSource = priceSource,
|
PriceSource = priceSource,
|
||||||
DiscountMode = r.DiscountMode,
|
DiscountMode = r.DiscountMode,
|
||||||
DiscountPct = r.DiscountPct,
|
DiscountPct = r.DiscountPct,
|
||||||
DiscountAmount = discountTotal,
|
DiscountAmount = calc.DiscountTotal,
|
||||||
NetUnitPrice = netUnit,
|
NetUnitPrice = calc.NetUnitPrice,
|
||||||
LineTotal = lineTotal,
|
LineTotal = calc.LineTotal,
|
||||||
TaxPct = r.TaxPct,
|
TaxPct = r.TaxPct,
|
||||||
TaxAmount = taxAmount,
|
TaxAmount = calc.TaxAmount,
|
||||||
IsFreeIssue = r.IsFreeIssue,
|
IsFreeIssue = r.IsFreeIssue,
|
||||||
ParentLineId = r.ParentLineId
|
ParentLineId = r.ParentLineId
|
||||||
});
|
});
|
||||||
@@ -285,21 +211,9 @@ public sealed class SalesSlipService : ISalesSlipService
|
|||||||
slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount;
|
slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
private SalesSlipSummaryDto MapSummary(SalesSlip x) => new(
|
||||||
{
|
|
||||||
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(
|
|
||||||
x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status,
|
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)
|
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());
|
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(
|
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ const sections = [
|
|||||||
href: "/dashboard/sales/free-issues",
|
href: "/dashboard/sales/free-issues",
|
||||||
icon: PackageX,
|
icon: PackageX,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
title: "Reports",
|
// title: "Reports",
|
||||||
description: "Sales report catalog and query entry point.",
|
// description: "Sales report catalog and query entry point.",
|
||||||
href: "/dashboard/sales/reports",
|
// href: "/dashboard/sales/reports",
|
||||||
icon: FileBarChart,
|
// icon: FileBarChart,
|
||||||
},
|
// },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SalesHubPage() {
|
export default function SalesHubPage() {
|
||||||
@@ -42,7 +42,7 @@ export default function SalesHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
Invoices, slips, free issues, and reporting in one place.
|
Invoices, slips, and free issues in one place.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const navItems: {
|
|||||||
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
|
{ 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: "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: "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 },
|
{ 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: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
|
||||||
{ title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock },
|
{ title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock },
|
||||||
{ title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote },
|
{ 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 },
|
{ title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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**.
|
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
|
## Phase 1 - Basic Standard Sales Module
|
||||||
@@ -42,6 +62,22 @@ Implement the minimum sales flow needed for both B2B and B2C:
|
|||||||
- Stock posting
|
- Stock posting
|
||||||
- Basic sales reports
|
- 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
|
### Not in Scope for Phase 1
|
||||||
- customer groups
|
- customer groups
|
||||||
- price lists
|
- price lists
|
||||||
@@ -51,6 +87,7 @@ Implement the minimum sales flow needed for both B2B and B2C:
|
|||||||
- approval workflow
|
- approval workflow
|
||||||
- returns and credit notes
|
- returns and credit notes
|
||||||
- advanced customer segmentation
|
- advanced customer segmentation
|
||||||
|
- fully unified sales provenance tracing across production, GRN, and sales documents
|
||||||
|
|
||||||
### Phase 1 Entity Design
|
### Phase 1 Entity Design
|
||||||
|
|
||||||
@@ -211,6 +248,20 @@ When an invoice or slip is posted:
|
|||||||
- maintain source document traceability
|
- maintain source document traceability
|
||||||
- update totals in the same transaction
|
- 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
|
### Phase 1 API Route List
|
||||||
- `GET /api/v1/customers`
|
- `GET /api/v1/customers`
|
||||||
- `GET /api/v1/customers/{id}`
|
- `GET /api/v1/customers/{id}`
|
||||||
@@ -239,6 +290,10 @@ When an invoice or slip is posted:
|
|||||||
- `GET /api/v1/reports/sales/{reportId}`
|
- `GET /api/v1/reports/sales/{reportId}`
|
||||||
- `POST /api/v1/reports/sales/query`
|
- `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
|
### Phase 1 Folder / Module Plan
|
||||||
- `Domain/Entities`
|
- `Domain/Entities`
|
||||||
- add `Customer`, `SalesInvoice`, `SalesInvoiceLine`, `SalesSlip`, `SalesSlipLine`
|
- 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 discounts calculate correctly by percentage and fixed value.
|
||||||
- Verify free issue lines post stock and appear in reports.
|
- Verify free issue lines post stock and appear in reports.
|
||||||
- Verify stock ledger entries are created once per posted document.
|
- 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.
|
- Verify Phase 1 routes remain stable before Phase 2 is added.
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|||||||
Reference in New Issue
Block a user