sales ,invoice ,slip and free issues BE fixes

This commit is contained in:
2026-08-02 01:12:09 +05:30
parent 59af50bf11
commit 3c5b476635
18 changed files with 543 additions and 74 deletions
@@ -0,0 +1,78 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
/// the existing sales-slip CRUD surface under a more business-friendly route.
/// </summary>
[Route("api/v1/free-issues")]
public sealed class FreeIssuesController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FreeIssueDto>> GetById(int freeIssueId, CancellationToken ct)
{
var result = await _slips.GetFreeIssueAsync(freeIssueId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{freeIssueId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
[HttpPost]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
public async Task<ActionResult<FreeIssueDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<FreeIssueDto>> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{freeIssueId:int}/post")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.PostAsync(freeIssueId, ct));
[HttpPost("{freeIssueId:int}/cancel")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
}
@@ -34,6 +34,12 @@ public sealed class SalesInvoicesController : ApiControllerBase
return Ok(result.Value);
}
[HttpGet("{salesInvoiceId:int}/posting-check")]
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
@@ -11,39 +11,25 @@ public sealed class SalesReportsController : ApiControllerBase
public SalesReportsController(ISalesReportService reports) => _reports = reports;
[HttpGet("daily-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesDailySummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesDailySummaryRowDto>>> DailySummary(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
=> Ok(await _reports.DailySummaryAsync(from, to, ct));
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
=> Ok(_reports.ListReports());
[HttpGet("item-wise")]
[ProducesResponseType(typeof(IReadOnlyList<SalesItemSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesItemSummaryRowDto>>> 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("{reportId}")]
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
{
var report = _reports.GetReport(reportId);
return report is null ? NotFound() : Ok(report);
}
[HttpGet("customer-wise")]
[ProducesResponseType(typeof(IReadOnlyList<SalesCustomerSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesCustomerSummaryRowDto>>> 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<SalesWarehouseSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesWarehouseSummaryRowDto>>> 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<SalesDiscountSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesDiscountSummaryRowDto>>> DiscountSummary(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
=> Ok(await _reports.DiscountSummaryAsync(from, to, ct));
[HttpGet("free-issue-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesFreeIssueSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesFreeIssueSummaryRowDto>>> FreeIssueSummary(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
=> Ok(await _reports.FreeIssueSummaryAsync(from, to, ct));
[HttpPost("query")]
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
{
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
}
}
@@ -0,0 +1,22 @@
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
public sealed class SalesSlipPromotionsController : ApiControllerBase
{
private readonly ISalesPromotionSuggestionService _suggestions;
public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions;
[HttpGet]
[ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
{
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -34,6 +34,12 @@ public sealed class SalesSlipsController : ApiControllerBase
return Ok(result.Value);
}
[HttpGet("{salesSlipId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
@@ -3,5 +3,5 @@ namespace ERPCore.Domain.Enums;
public enum SalesDiscountMode
{
Percentage = 1,
FixedAmount = 2
Amount = 2
}
@@ -24,6 +24,14 @@ public sealed record SalesInvoiceSummaryDto(
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
public sealed record SalesInvoicePostingIssueDto(
int SalesInvoiceLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
public sealed record SalesInvoicePostingCheckDto(
int SalesInvoiceId, string InvoiceNo, SalesInvoiceStatus Status, bool CanPost,
IReadOnlyList<SalesInvoicePostingIssueDto> Issues);
public sealed class CreateSalesInvoiceLineRequest
{
[Required] public int ItemId { get; set; }
@@ -0,0 +1,23 @@
namespace ERPCore.Dtos.Sales;
public sealed record SalesFreeIssueRewardOptionDto(
int ItemId,
string Sku,
string Name,
decimal? SalePrice);
public sealed record SalesFreeIssueSuggestionLineDto(
int SalesSlipLineId,
int ItemId,
string ItemSku,
string ItemName,
decimal Qty,
decimal SuggestedFreeQty,
decimal TriggerQty,
IReadOnlyList<SalesFreeIssueRewardOptionDto> RewardOptions);
public sealed record SalesFreeIssueSuggestionDto(
int SalesSlipId,
string SlipNo,
DateTime SlipDate,
IReadOnlyList<SalesFreeIssueSuggestionLineDto> Lines);
@@ -0,0 +1,21 @@
namespace ERPCore.Dtos.Sales;
public sealed record SalesReportDefinitionDto(
string Id,
string Name,
string Description,
IReadOnlyList<string> SupportedFilters);
public sealed record SalesReportQueryRequest(
string ReportType,
DateOnly From,
DateOnly To,
int? ItemId = null,
int? CustomerId = null,
int? WarehouseId = null);
public sealed record SalesReportQueryResponse(
string ReportType,
DateOnly From,
DateOnly To,
IReadOnlyList<object> Rows);
@@ -23,6 +23,25 @@ public sealed record SalesSlipSummaryDto(
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
SalesSlipTotalsDto Totals, DateTime CreatedAt);
public sealed record SalesSlipPostingIssueDto(
int SalesSlipLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
decimal RequestedQty, decimal AvailableQty, decimal ShortQty, bool IsFreeIssue);
public sealed record SalesSlipPostingCheckDto(
int SalesSlipId, string SlipNo, SalesSlipStatus Status, bool CanPost,
IReadOnlyList<SalesSlipPostingIssueDto> Issues);
public sealed record FreeIssueSummaryDto(
int SalesSlipId, string SlipNo, SalesSlipStatus Status, DateTime CreatedAt,
int WarehouseId, string WarehouseName, int ItemId, string ItemSku, string ItemName,
int UomId, string UomName, decimal Qty, decimal FreeQty, string SchemeLabel);
public sealed record FreeIssueDto(
int SalesSlipId, string SlipNo, DateTime SlipDate, SalesSlipStatus Status,
int CustomerId, string CustomerSnapshotName, int WarehouseId, string WarehouseName,
int CashierUserId, DateTime CreatedAt, DateTime? UpdatedAt, FreeIssueSummaryDto Summary,
IReadOnlyList<SalesSlipLineDto> Lines);
public sealed class CreateSalesSlipLineRequest
{
[Required] public int ItemId { get; set; }
@@ -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);
}
}
+85 -20
View File
@@ -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,10 +131,9 @@ 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
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,
@@ -85,14 +143,18 @@ public sealed class SalesReportService : ISalesReportService
l.DiscountAmount,
l.TaxAmount,
l.LineTotal,
l.WarehouseId
}));
if (itemId is not null) invoiceQuery = invoiceQuery.Where(x => x.ItemId == itemId);
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
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 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,
@@ -102,12 +164,18 @@ public sealed class SalesReportService : ISalesReportService
l.DiscountAmount,
l.TaxAmount,
l.LineTotal,
l.WarehouseId
}));
if (itemId is not null) slipQuery = slipQuery.Where(x => x.ItemId == itemId);
x.WarehouseId
}))
.ToListAsync(ct);
var rows = await invoiceQuery.Concat(slipQuery)
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)
+112 -1
View File
@@ -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),