diff --git a/Backend/ERPCore/Controllers/SalesReportsController.cs b/Backend/ERPCore/Controllers/SalesReportsController.cs new file mode 100644 index 0000000..b2edfa8 --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesReportsController.cs @@ -0,0 +1,49 @@ +using ERPCore.Dtos.Sales; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +[Route("api/v1/reports/sales")] +public sealed class SalesReportsController : ApiControllerBase +{ + private readonly ISalesReportService _reports; + + public SalesReportsController(ISalesReportService reports) => _reports = reports; + + [HttpGet("daily-summary")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> DailySummary( + [FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct) + => Ok(await _reports.DailySummaryAsync(from, to, ct)); + + [HttpGet("item-wise")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> ItemWise( + [FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? itemId, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _reports.ItemSummaryAsync(from, to, itemId, warehouseId, ct)); + + [HttpGet("customer-wise")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> CustomerWise( + [FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? customerId, CancellationToken ct) + => Ok(await _reports.CustomerSummaryAsync(from, to, customerId, ct)); + + [HttpGet("warehouse-wise")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> WarehouseWise( + [FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _reports.WarehouseSummaryAsync(from, to, warehouseId, ct)); + + [HttpGet("discount-summary")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> DiscountSummary( + [FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct) + => Ok(await _reports.DiscountSummaryAsync(from, to, ct)); + + [HttpGet("free-issue-summary")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> FreeIssueSummary( + [FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct) + => Ok(await _reports.FreeIssueSummaryAsync(from, to, ct)); +} diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index f8f8f5e..511d891 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -10,7 +10,7 @@ public sealed record SalesInvoiceLineDto( decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); public sealed record SalesInvoiceTotalsDto( - decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, + decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, decimal RoundOff, decimal NetPayable, decimal PaidAmount, decimal BalanceAmount); public sealed record SalesInvoiceDto( diff --git a/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs new file mode 100644 index 0000000..bea474c --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs @@ -0,0 +1,68 @@ +namespace ERPCore.Dtos.Sales; + +public sealed record SalesDailySummaryRowDto( + DateOnly Date, + int InvoiceCount, + int SlipCount, + decimal InvoiceSubtotal, + decimal SlipSubtotal, + decimal DiscountTotal, + decimal FreeQtyTotal, + decimal TaxTotal, + decimal GrandTotal); + +public sealed record SalesItemSummaryRowDto( + int ItemId, + string ItemName, + decimal SoldQty, + decimal FreeQty, + decimal GrossAmount, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesCustomerSummaryRowDto( + int CustomerId, + string CustomerName, + int InvoiceCount, + int SlipCount, + decimal SoldQty, + decimal FreeQty, + decimal GrossAmount, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesWarehouseSummaryRowDto( + int WarehouseId, + string WarehouseName, + int InvoiceCount, + int SlipCount, + decimal SoldQty, + decimal FreeQty, + decimal GrossAmount, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesDiscountSummaryRowDto( + string DocumentType, + string DocumentNo, + DateTime DocumentDate, + string CustomerName, + decimal Subtotal, + decimal DiscountTotal, + decimal TaxTotal, + decimal NetAmount); + +public sealed record SalesFreeIssueSummaryRowDto( + string DocumentType, + string DocumentNo, + DateTime DocumentDate, + string CustomerName, + int ItemId, + string ItemName, + decimal FreeQty, + decimal FreeValue, + int WarehouseId, + string WarehouseName); diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs index 11a4597..72270ad 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -10,7 +10,7 @@ public sealed record SalesSlipLineDto( decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); public sealed record SalesSlipTotalsDto( - decimal Subtotal, decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, + decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount); public sealed record SalesSlipDto( diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index f18e277..c89a8c3 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -93,6 +93,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Stock transactions + reference data (docs/11 §5–6) builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs new file mode 100644 index 0000000..6e6e591 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs @@ -0,0 +1,13 @@ +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesReportService +{ + Task> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default); + Task> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default); + Task> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default); + Task> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default); + Task> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default); + Task> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 9736cbe..5f06694 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -182,7 +182,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService var priceSource = resolved.PriceSource; var gross = r.Qty * unitPrice; - var discountTotal = CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount); + 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); @@ -239,11 +241,11 @@ public sealed class SalesInvoiceService : ISalesInvoiceService private static SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto( - x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), 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.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), + 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()); } diff --git a/Backend/ERPCore/Services/SalesReportService.cs b/Backend/ERPCore/Services/SalesReportService.cs new file mode 100644 index 0000000..77e5d08 --- /dev/null +++ b/Backend/ERPCore/Services/SalesReportService.cs @@ -0,0 +1,297 @@ +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 SalesReportService : ISalesReportService +{ + private readonly IRepository _invoices; + private readonly IRepository _slips; + + public SalesReportService(IRepository invoices, IRepository slips) + { + _invoices = invoices; + _slips = slips; + } + + public async Task> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + 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)) + .GroupBy(x => DateOnly.FromDateTime(x.InvoiceDate)) + .Select(g => new + { + Date = g.Key, + InvoiceCount = g.Count(), + SlipCount = 0, + InvoiceSubtotal = g.Sum(x => x.Subtotal), + SlipSubtotal = 0m, + DiscountTotal = g.Sum(x => x.DiscountTotal), + FreeQtyTotal = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + TaxTotal = g.Sum(x => x.TaxTotal), + GrandTotal = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + 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)) + .GroupBy(x => DateOnly.FromDateTime(x.SlipDate)) + .Select(g => new + { + Date = g.Key, + InvoiceCount = 0, + SlipCount = g.Count(), + InvoiceSubtotal = 0m, + SlipSubtotal = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + FreeQtyTotal = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + TaxTotal = g.Sum(x => x.TaxTotal), + GrandTotal = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => x.Date) + .OrderBy(x => x.Key) + .Select(g => new SalesDailySummaryRowDto( + g.Key, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.InvoiceSubtotal), + g.Sum(x => x.SlipSubtotal), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.FreeQtyTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.GrandTotal))) + .ToList(); + } + + public async Task> 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 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); + + var rows = await invoiceQuery.Concat(slipQuery) + .GroupBy(x => new { x.ItemId, x.Description }) + .Select(g => new SalesItemSummaryRowDto( + g.Key.ItemId, + g.Key.Description, + g.Sum(x => x.Qty), + g.Sum(x => x.FreeQty), + 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; + } + + public async Task> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default) + { + var invoiceQuery = _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (customerId is not null) invoiceQuery = invoiceQuery.Where(x => x.CustomerId == customerId); + + var slipQuery = _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (customerId is not null) slipQuery = slipQuery.Where(x => x.CustomerId == customerId); + + var invoiceRows = await invoiceQuery + .GroupBy(x => new { x.CustomerId, x.CustomerSnapshotName }) + .Select(g => new + { + g.Key.CustomerId, + CustomerName = g.Key.CustomerSnapshotName, + InvoiceCount = g.Count(), + SlipCount = 0, + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + var slipRows = await slipQuery + .GroupBy(x => new { x.CustomerId, x.CustomerSnapshotName }) + .Select(g => new + { + g.Key.CustomerId, + CustomerName = g.Key.CustomerSnapshotName, + InvoiceCount = 0, + SlipCount = g.Count(), + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => new { x.CustomerId, x.CustomerName }) + .OrderByDescending(g => g.Sum(x => x.NetAmount)) + .Select(g => new SalesCustomerSummaryRowDto( + g.Key.CustomerId, + g.Key.CustomerName, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.SoldQty), + g.Sum(x => x.FreeQty), + g.Sum(x => x.GrossAmount), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.NetAmount))) + .ToList(); + } + + public async Task> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default) + { + var invoiceQuery = _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) invoiceQuery = invoiceQuery.Where(x => x.WarehouseId == warehouseId); + + var slipQuery = _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) slipQuery = slipQuery.Where(x => x.WarehouseId == warehouseId); + + var invoiceRows = await invoiceQuery + .GroupBy(x => new { x.WarehouseId, x.Warehouse!.Name }) + .Select(g => new + { + g.Key.WarehouseId, + WarehouseName = g.Key.Name, + InvoiceCount = g.Count(), + SlipCount = 0, + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + var slipRows = await slipQuery + .GroupBy(x => new { x.WarehouseId, x.Warehouse!.Name }) + .Select(g => new + { + g.Key.WarehouseId, + WarehouseName = g.Key.Name, + InvoiceCount = 0, + SlipCount = g.Count(), + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => new { x.WarehouseId, x.WarehouseName }) + .OrderByDescending(g => g.Sum(x => x.NetAmount)) + .Select(g => new SalesWarehouseSummaryRowDto( + g.Key.WarehouseId, + g.Key.WarehouseName, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.SoldQty), + g.Sum(x => x.FreeQty), + g.Sum(x => x.GrossAmount), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.NetAmount))) + .ToList(); + } + + public async Task> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + var invoices = await _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue) && x.DiscountTotal > 0m) + .Select(x => new SalesDiscountSummaryRowDto("Invoice", x.InvoiceNo, x.InvoiceDate, x.CustomerSnapshotName, x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal)) + .ToListAsync(ct); + + var slips = await _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue) && x.DiscountTotal > 0m) + .Select(x => new SalesDiscountSummaryRowDto("Slip", x.SlipNo, x.SlipDate, x.CustomerSnapshotName, x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal)) + .ToListAsync(ct); + + return invoices.Concat(slips).OrderByDescending(x => x.DiscountTotal).ToList(); + } + + public async Task> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + 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.Where(l => l.FreeQty > 0m).Select(l => new SalesFreeIssueSummaryRowDto( + "Invoice", + x.InvoiceNo, + x.InvoiceDate, + x.CustomerSnapshotName, + l.ItemId, + l.Description, + l.FreeQty, + l.FreeQty * l.UnitPrice, + l.WarehouseId, + x.Warehouse!.Name))) + .ToListAsync(ct); + + 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.Where(l => l.FreeQty > 0m).Select(l => new SalesFreeIssueSummaryRowDto( + "Slip", + x.SlipNo, + x.SlipDate, + x.CustomerSnapshotName, + l.ItemId, + l.Description, + l.FreeQty, + l.FreeQty * l.UnitPrice, + l.WarehouseId, + x.Warehouse!.Name))) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows).OrderByDescending(x => x.FreeQty).ToList(); + } +} diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs index a92a355..1fa58a2 100644 --- a/Backend/ERPCore/Services/SalesSlipService.cs +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -183,7 +183,9 @@ public sealed class SalesSlipService : ISalesSlipService var priceSource = resolved.PriceSource; var gross = r.Qty * unitPrice; - var discountTotal = CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount); + 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); @@ -237,10 +239,10 @@ public sealed class SalesSlipService : ISalesSlipService private static SalesSlipSummaryDto MapSummary(SalesSlip x) => new( x.SalesSlipId, x.SlipNo, x.SlipDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.Status, - new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt); + new SalesSlipTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), x.CreatedAt); 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.TaxTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount), + 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()); }