sales ,invoice ,slip and free issues BE fixes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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