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);
|
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]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
|
||||||
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
|
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;
|
public SalesReportsController(ISalesReportService reports) => _reports = reports;
|
||||||
|
|
||||||
[HttpGet("daily-summary")]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesDailySummaryRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesDailySummaryRowDto>>> DailySummary(
|
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
=> Ok(_reports.ListReports());
|
||||||
=> Ok(await _reports.DailySummaryAsync(from, to, ct));
|
|
||||||
|
|
||||||
[HttpGet("item-wise")]
|
[HttpGet("{reportId}")]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesItemSummaryRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesItemSummaryRowDto>>> ItemWise(
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? itemId, [FromQuery] int? warehouseId, CancellationToken ct)
|
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
|
||||||
=> Ok(await _reports.ItemSummaryAsync(from, to, itemId, warehouseId, ct));
|
{
|
||||||
|
var report = _reports.GetReport(reportId);
|
||||||
[HttpGet("customer-wise")]
|
return report is null ? NotFound() : Ok(report);
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesCustomerSummaryRowDto>), StatusCodes.Status200OK)]
|
}
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesCustomerSummaryRowDto>>> CustomerWise(
|
|
||||||
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? customerId, CancellationToken ct)
|
[HttpPost("query")]
|
||||||
=> Ok(await _reports.CustomerSummaryAsync(from, to, customerId, ct));
|
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
|
||||||
[HttpGet("warehouse-wise")]
|
{
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<SalesWarehouseSummaryRowDto>), StatusCodes.Status200OK)]
|
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
|
||||||
public async Task<ActionResult<IReadOnlyList<SalesWarehouseSummaryRowDto>>> WarehouseWise(
|
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
|
||||||
[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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
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]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
|
||||||
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ namespace ERPCore.Domain.Enums;
|
|||||||
public enum SalesDiscountMode
|
public enum SalesDiscountMode
|
||||||
{
|
{
|
||||||
Percentage = 1,
|
Percentage = 1,
|
||||||
FixedAmount = 2
|
Amount = 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ public sealed record SalesInvoiceSummaryDto(
|
|||||||
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
|
||||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
|
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
|
public sealed class CreateSalesInvoiceLineRequest
|
||||||
{
|
{
|
||||||
[Required] public int ItemId { get; set; }
|
[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,
|
string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
|
||||||
SalesSlipTotalsDto Totals, DateTime CreatedAt);
|
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
|
public sealed class CreateSalesSlipLineRequest
|
||||||
{
|
{
|
||||||
[Required] public int ItemId { get; set; }
|
[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<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, 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>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, 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
|
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<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<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);
|
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<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, 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>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
Task<SalesSlipDto> PostAsync(int salesSlipId, 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);
|
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)
|
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
|
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)
|
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
|
? discountValue
|
||||||
: gross * (discountPct / 100m);
|
: 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.Dtos.Sales;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
public sealed class SalesReportService : ISalesReportService
|
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<SalesInvoice> _invoices;
|
||||||
private readonly IRepository<SalesSlip> _slips;
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
|
||||||
@@ -18,6 +29,54 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
_slips = slips;
|
_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)
|
public async Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoiceRows = await _invoices.Query().AsNoTracking()
|
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)
|
public async Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var invoiceLines = _invoices.Query().AsNoTracking()
|
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));
|
.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);
|
.SelectMany(x => x.Lines.Select(l => new
|
||||||
var invoiceQuery = invoiceLines.SelectMany(x => x.Lines.Select(l => new
|
|
||||||
{
|
{
|
||||||
l.ItemId,
|
l.ItemId,
|
||||||
l.Description,
|
l.Description,
|
||||||
@@ -85,14 +143,18 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
l.DiscountAmount,
|
l.DiscountAmount,
|
||||||
l.TaxAmount,
|
l.TaxAmount,
|
||||||
l.LineTotal,
|
l.LineTotal,
|
||||||
l.WarehouseId
|
x.WarehouseId
|
||||||
}));
|
}))
|
||||||
if (itemId is not null) invoiceQuery = invoiceQuery.Where(x => x.ItemId == itemId);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var slipLines = _slips.Query().AsNoTracking()
|
if (warehouseId is not null)
|
||||||
.Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
invoiceRows = invoiceRows.Where(x => x.WarehouseId == warehouseId).ToList();
|
||||||
if (warehouseId is not null) slipLines = slipLines.Where(x => x.WarehouseId == warehouseId);
|
if (itemId is not null)
|
||||||
var slipQuery = slipLines.SelectMany(x => x.Lines.Select(l => new
|
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.ItemId,
|
||||||
l.Description,
|
l.Description,
|
||||||
@@ -102,12 +164,18 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
l.DiscountAmount,
|
l.DiscountAmount,
|
||||||
l.TaxAmount,
|
l.TaxAmount,
|
||||||
l.LineTotal,
|
l.LineTotal,
|
||||||
l.WarehouseId
|
x.WarehouseId
|
||||||
}));
|
}))
|
||||||
if (itemId is not null) slipQuery = slipQuery.Where(x => x.ItemId == itemId);
|
.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 })
|
.GroupBy(x => new { x.ItemId, x.Description })
|
||||||
|
.OrderByDescending(g => g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount))
|
||||||
.Select(g => new SalesItemSummaryRowDto(
|
.Select(g => new SalesItemSummaryRowDto(
|
||||||
g.Key.ItemId,
|
g.Key.ItemId,
|
||||||
g.Key.Description,
|
g.Key.Description,
|
||||||
@@ -116,11 +184,8 @@ public sealed class SalesReportService : ISalesReportService
|
|||||||
g.Sum(x => x.Gross),
|
g.Sum(x => x.Gross),
|
||||||
g.Sum(x => x.DiscountAmount),
|
g.Sum(x => x.DiscountAmount),
|
||||||
g.Sum(x => x.TaxAmount),
|
g.Sum(x => x.TaxAmount),
|
||||||
g.Sum(x => x.LineTotal + x.TaxAmount)))
|
g.Sum(x => x.LineTotal) + g.Sum(x => x.TaxAmount)))
|
||||||
.OrderByDescending(x => x.NetAmount)
|
.ToList();
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default)
|
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);
|
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)
|
public async Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct);
|
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)
|
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
|
? discountValue
|
||||||
: gross * (discountPct / 100m);
|
: 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,
|
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);
|
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(
|
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,
|
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),
|
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