sales ,invoice ,slip and free issues BE fixes
This commit is contained in:
@@ -9,6 +9,7 @@ public interface ISalesInvoiceService
|
||||
{
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ERPCore.Dtos.Sales;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesPromotionSuggestionService
|
||||
{
|
||||
Task<SalesFreeIssueSuggestionDto?> GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -4,6 +4,9 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesReportService
|
||||
{
|
||||
IReadOnlyList<SalesReportDefinitionDto> ListReports();
|
||||
SalesReportDefinitionDto? GetReport(string reportId);
|
||||
Task<IReadOnlyList<object>> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default);
|
||||
|
||||
@@ -9,6 +9,9 @@ public interface ISalesSlipService
|
||||
{
|
||||
Task<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<SalesSlipPostingCheckDto> CheckPostingAsync(int salesSlipId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default);
|
||||
|
||||
@@ -69,6 +69,41 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
return invoice is null ? null : new ETagged<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesInvoicePostingCheckDto> 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<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)
|
||||
{
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
|
||||
@@ -228,7 +263,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
||||
{
|
||||
var computed = mode == SalesDiscountMode.FixedAmount
|
||||
var computed = mode == SalesDiscountMode.Amount
|
||||
? discountValue
|
||||
: gross * (discountPct / 100m);
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
|
||||
{
|
||||
private const decimal FreeIssueThreshold = 10m;
|
||||
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
public SalesPromotionSuggestionService(IRepository<SalesSlip> slips, IRepository<Item> items)
|
||||
{
|
||||
_slips = slips;
|
||||
_items = items;
|
||||
}
|
||||
|
||||
public async Task<SalesFreeIssueSuggestionDto?> GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default)
|
||||
{
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||
|
||||
if (slip is null) return null;
|
||||
|
||||
var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
|
||||
var candidateItems = await _items.Query().AsNoTracking()
|
||||
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
|
||||
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
|
||||
|
||||
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
|
||||
{
|
||||
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
||||
|
||||
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
|
||||
if (freeQty <= 0m) continue;
|
||||
|
||||
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
||||
{
|
||||
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
|
||||
};
|
||||
|
||||
var alternates = await _items.Query().AsNoTracking()
|
||||
.Where(x => x.Status == EntityStatus.Active && x.CategoryId == item.CategoryId && x.ItemId != item.ItemId)
|
||||
.OrderBy(x => x.Name)
|
||||
.Take(3)
|
||||
.Select(x => new SalesFreeIssueRewardOptionDto(x.ItemId, x.Sku, x.Name, x.SalePrice))
|
||||
.ToListAsync(ct);
|
||||
|
||||
rewardOptions.AddRange(alternates.Where(x => rewardOptions.All(r => r.ItemId != x.ItemId)));
|
||||
|
||||
suggestions.Add(new SalesFreeIssueSuggestionLineDto(
|
||||
line.SalesSlipLineId,
|
||||
item.ItemId,
|
||||
item.Sku,
|
||||
item.Name,
|
||||
line.Qty,
|
||||
freeQty,
|
||||
FreeIssueThreshold,
|
||||
rewardOptions));
|
||||
}
|
||||
|
||||
return suggestions.Count == 0
|
||||
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
||||
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,23 @@ using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class SalesReportService : ISalesReportService
|
||||
{
|
||||
private static readonly SalesReportDefinitionDto[] ReportDefinitions =
|
||||
[
|
||||
new("daily-summary", "Daily Summary", "Aggregated sales by day across posted invoices and slips.", ["from", "to"]),
|
||||
new("item-summary", "Item Summary", "Aggregated sales by item across posted invoices and slips.", ["from", "to", "itemId", "warehouseId"]),
|
||||
new("customer-summary", "Customer Summary", "Aggregated sales by customer across posted invoices and slips.", ["from", "to", "customerId"]),
|
||||
new("warehouse-summary", "Warehouse Summary", "Aggregated sales by warehouse across posted invoices and slips.", ["from", "to", "warehouseId"]),
|
||||
new("discount-summary", "Discount Summary", "Documents with discounts applied.", ["from", "to"]),
|
||||
new("free-issue-summary", "Free Issue Summary", "Lines with free quantities issued.", ["from", "to"])
|
||||
];
|
||||
|
||||
private readonly IRepository<SalesInvoice> _invoices;
|
||||
private readonly IRepository<SalesSlip> _slips;
|
||||
|
||||
@@ -18,6 +29,54 @@ public sealed class SalesReportService : ISalesReportService
|
||||
_slips = slips;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SalesReportDefinitionDto> ListReports() => ReportDefinitions;
|
||||
|
||||
public SalesReportDefinitionDto? GetReport(string reportId)
|
||||
{
|
||||
var normalized = reportId.Trim().ToLowerInvariant();
|
||||
return ReportDefinitions.FirstOrDefault(r => r.Id == normalized);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<object>> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = reportType.Trim().ToLowerInvariant();
|
||||
ValidateFilters(normalized, itemId, customerId, warehouseId);
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"daily" or "daily-summary" => (await DailySummaryAsync(from, to, ct)).Cast<object>().ToList(),
|
||||
"item" or "item-summary" or "item-wise" => (await ItemSummaryAsync(from, to, itemId, warehouseId, ct)).Cast<object>().ToList(),
|
||||
"customer" or "customer-summary" or "customer-wise" => (await CustomerSummaryAsync(from, to, customerId, ct)).Cast<object>().ToList(),
|
||||
"warehouse" or "warehouse-summary" or "warehouse-wise" => (await WarehouseSummaryAsync(from, to, warehouseId, ct)).Cast<object>().ToList(),
|
||||
"discount" or "discount-summary" => (await DiscountSummaryAsync(from, to, ct)).Cast<object>().ToList(),
|
||||
"free-issue" or "free-issue-summary" => (await FreeIssueSummaryAsync(from, to, ct)).Cast<object>().ToList(),
|
||||
_ => throw new DomainException("INVALID_REPORT_TYPE", $"Unsupported sales report type '{reportType}'.", 400)
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateFilters(string reportType, int? itemId, int? customerId, int? warehouseId)
|
||||
{
|
||||
var allowed = reportType switch
|
||||
{
|
||||
"daily" or "daily-summary" => new FilterSet(false, false, false),
|
||||
"item" or "item-summary" or "item-wise" => new FilterSet(true, false, true),
|
||||
"customer" or "customer-summary" or "customer-wise" => new FilterSet(false, true, false),
|
||||
"warehouse" or "warehouse-summary" or "warehouse-wise" => new FilterSet(false, false, true),
|
||||
"discount" or "discount-summary" => new FilterSet(false, false, false),
|
||||
"free-issue" or "free-issue-summary" => new FilterSet(false, false, false),
|
||||
_ => throw new DomainException("INVALID_REPORT_TYPE", $"Unsupported sales report type '{reportType}'.", 400)
|
||||
};
|
||||
|
||||
if (!allowed.Item && itemId is not null)
|
||||
throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'itemId' is not valid for report type '{reportType}'.", 400);
|
||||
if (!allowed.Customer && customerId is not null)
|
||||
throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'customerId' is not valid for report type '{reportType}'.", 400);
|
||||
if (!allowed.Warehouse && warehouseId is not null)
|
||||
throw new DomainException("INVALID_REPORT_FILTER", $"Filter 'warehouseId' is not valid for report type '{reportType}'.", 400);
|
||||
}
|
||||
|
||||
private readonly record struct FilterSet(bool Item, bool Customer, bool Warehouse);
|
||||
|
||||
public async Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default)
|
||||
{
|
||||
var invoiceRows = await _invoices.Query().AsNoTracking()
|
||||
@@ -72,42 +131,51 @@ public sealed class SalesReportService : ISalesReportService
|
||||
|
||||
public async Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var invoiceLines = _invoices.Query().AsNoTracking()
|
||||
.Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
if (warehouseId is not null) invoiceLines = invoiceLines.Where(x => x.WarehouseId == warehouseId);
|
||||
var invoiceQuery = invoiceLines.SelectMany(x => x.Lines.Select(l => new
|
||||
{
|
||||
l.ItemId,
|
||||
l.Description,
|
||||
l.Qty,
|
||||
l.FreeQty,
|
||||
Gross = l.Qty * l.UnitPrice,
|
||||
l.DiscountAmount,
|
||||
l.TaxAmount,
|
||||
l.LineTotal,
|
||||
l.WarehouseId
|
||||
}));
|
||||
if (itemId is not null) invoiceQuery = invoiceQuery.Where(x => x.ItemId == itemId);
|
||||
var invoiceRows = await _invoices.Query().AsNoTracking()
|
||||
.Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue))
|
||||
.SelectMany(x => x.Lines.Select(l => new
|
||||
{
|
||||
l.ItemId,
|
||||
l.Description,
|
||||
l.Qty,
|
||||
l.FreeQty,
|
||||
Gross = l.Qty * l.UnitPrice,
|
||||
l.DiscountAmount,
|
||||
l.TaxAmount,
|
||||
l.LineTotal,
|
||||
x.WarehouseId
|
||||
}))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var slipLines = _slips.Query().AsNoTracking()
|
||||
.Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
if (warehouseId is not null) slipLines = slipLines.Where(x => x.WarehouseId == warehouseId);
|
||||
var slipQuery = slipLines.SelectMany(x => x.Lines.Select(l => new
|
||||
{
|
||||
l.ItemId,
|
||||
l.Description,
|
||||
l.Qty,
|
||||
l.FreeQty,
|
||||
Gross = l.Qty * l.UnitPrice,
|
||||
l.DiscountAmount,
|
||||
l.TaxAmount,
|
||||
l.LineTotal,
|
||||
l.WarehouseId
|
||||
}));
|
||||
if (itemId is not null) slipQuery = slipQuery.Where(x => x.ItemId == itemId);
|
||||
if (warehouseId is not null)
|
||||
invoiceRows = invoiceRows.Where(x => x.WarehouseId == warehouseId).ToList();
|
||||
if (itemId is not null)
|
||||
invoiceRows = invoiceRows.Where(x => x.ItemId == itemId).ToList();
|
||||
|
||||
var rows = await invoiceQuery.Concat(slipQuery)
|
||||
var slipRows = await _slips.Query().AsNoTracking()
|
||||
.Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue))
|
||||
.SelectMany(x => x.Lines.Select(l => new
|
||||
{
|
||||
l.ItemId,
|
||||
l.Description,
|
||||
l.Qty,
|
||||
l.FreeQty,
|
||||
Gross = l.Qty * l.UnitPrice,
|
||||
l.DiscountAmount,
|
||||
l.TaxAmount,
|
||||
l.LineTotal,
|
||||
x.WarehouseId
|
||||
}))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (warehouseId is not null)
|
||||
slipRows = slipRows.Where(x => x.WarehouseId == warehouseId).ToList();
|
||||
if (itemId is not null)
|
||||
slipRows = slipRows.Where(x => x.ItemId == itemId).ToList();
|
||||
|
||||
return invoiceRows.Concat(slipRows)
|
||||
.GroupBy(x => new { x.ItemId, x.Description })
|
||||
.OrderByDescending(g => g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount))
|
||||
.Select(g => new SalesItemSummaryRowDto(
|
||||
g.Key.ItemId,
|
||||
g.Key.Description,
|
||||
@@ -116,11 +184,8 @@ public sealed class SalesReportService : ISalesReportService
|
||||
g.Sum(x => x.Gross),
|
||||
g.Sum(x => x.DiscountAmount),
|
||||
g.Sum(x => x.TaxAmount),
|
||||
g.Sum(x => x.LineTotal + x.TaxAmount)))
|
||||
.OrderByDescending(x => x.NetAmount)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows;
|
||||
g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default)
|
||||
|
||||
@@ -70,6 +70,66 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
return slip is null ? null : new ETagged<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<FreeIssueSummaryDto>> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesSlip> q = _slips.Query().AsNoTracking().Include(x => x.Lines);
|
||||
q = q.Where(x => x.Lines.Any(l => l.IsFreeIssue || l.FreeQty > 0m));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(x => EF.Functions.ILike(x.SlipNo, $"%{term}%") || EF.Functions.ILike(x.CustomerSnapshotName, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.SalesSlipId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
return PagedResponse<FreeIssueSummaryDto>.Create(rows.Select(MapFreeIssueSummary).ToList(), query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default)
|
||||
{
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<SalesSlipPostingCheckDto> 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<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)
|
||||
{
|
||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
|
||||
@@ -227,7 +287,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
|
||||
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
|
||||
{
|
||||
var computed = mode == SalesDiscountMode.FixedAmount
|
||||
var computed = mode == SalesDiscountMode.Amount
|
||||
? discountValue
|
||||
: gross * (discountPct / 100m);
|
||||
|
||||
@@ -241,6 +301,57 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
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);
|
||||
|
||||
private FreeIssueSummaryDto MapFreeIssueSummary(SalesSlip x)
|
||||
{
|
||||
var line = x.Lines.FirstOrDefault();
|
||||
var item = line is null ? null : _items.Query().AsNoTracking()
|
||||
.Where(i => i.ItemId == line.ItemId)
|
||||
.Select(i => new { i.ItemId, i.Sku, i.Name, i.BaseUomId })
|
||||
.FirstOrDefault();
|
||||
var uom = line is null ? null : _uoms.Query().AsNoTracking()
|
||||
.Where(u => u.UomId == line.UomId)
|
||||
.Select(u => new { u.UomId, u.Name })
|
||||
.FirstOrDefault();
|
||||
var warehouse = _warehouses.Query().AsNoTracking()
|
||||
.Where(w => w.WarehouseId == x.WarehouseId)
|
||||
.Select(w => new { w.WarehouseId, w.Name })
|
||||
.FirstOrDefault();
|
||||
return new FreeIssueSummaryDto(
|
||||
x.SalesSlipId,
|
||||
x.SlipNo,
|
||||
x.Status,
|
||||
x.CreatedAt,
|
||||
x.WarehouseId,
|
||||
warehouse?.Name ?? $"Warehouse {x.WarehouseId}",
|
||||
line?.ItemId ?? 0,
|
||||
item?.Sku ?? $"SKU-{line?.ItemId ?? 0}",
|
||||
item?.Name ?? line?.Description ?? "—",
|
||||
line?.UomId ?? 0,
|
||||
uom?.Name ?? $"UOM {line?.UomId ?? 0}",
|
||||
line?.Qty ?? 0m,
|
||||
line?.FreeQty ?? 0m,
|
||||
line is null ? "No line" : $"Buy {line.Qty} Get {line.FreeQty}");
|
||||
}
|
||||
|
||||
private FreeIssueDto MapFreeIssue(SalesSlip x)
|
||||
{
|
||||
var summary = MapFreeIssueSummary(x);
|
||||
return new FreeIssueDto(
|
||||
x.SalesSlipId,
|
||||
x.SlipNo,
|
||||
x.SlipDate,
|
||||
x.Status,
|
||||
x.CustomerId,
|
||||
x.CustomerSnapshotName,
|
||||
x.WarehouseId,
|
||||
summary.WarehouseName,
|
||||
x.CashierUserId,
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt,
|
||||
summary,
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user