From 3c5b47663559e3b432a8ffba384d6738f18cff5e Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 2 Aug 2026 01:12:09 +0530 Subject: [PATCH] sales ,invoice ,slip and free issues BE fixes --- .../Controllers/FreeIssuesController.cs | 78 ++++++++++ .../Controllers/SalesInvoicesController.cs | 6 + .../Controllers/SalesReportsController.cs | 52 +++---- .../SalesSlipPromotionsController.cs | 22 +++ .../Controllers/SalesSlipsController.cs | 6 + .../ERPCore/Domain/Enums/SalesDiscountMode.cs | 2 +- .../ERPCore/Dtos/Sales/SalesInvoiceDtos.cs | 8 + .../Sales/SalesPromotionSuggestionDtos.cs | 23 +++ .../Dtos/Sales/SalesReportQueryDtos.cs | 21 +++ Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs | 19 +++ .../Interfaces/ISalesInvoiceService.cs | 1 + .../ISalesPromotionSuggestionService.cs | 8 + .../Interfaces/ISalesReportService.cs | 3 + .../Services/Interfaces/ISalesSlipService.cs | 3 + .../ERPCore/Services/SalesInvoiceService.cs | 37 ++++- .../SalesPromotionSuggestionService.cs | 74 +++++++++ .../ERPCore/Services/SalesReportService.cs | 141 +++++++++++++----- Backend/ERPCore/Services/SalesSlipService.cs | 113 +++++++++++++- 18 files changed, 543 insertions(+), 74 deletions(-) create mode 100644 Backend/ERPCore/Controllers/FreeIssuesController.cs create mode 100644 Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs create mode 100644 Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs create mode 100644 Backend/ERPCore/Dtos/Sales/SalesReportQueryDtos.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISalesPromotionSuggestionService.cs create mode 100644 Backend/ERPCore/Services/SalesPromotionSuggestionService.cs diff --git a/Backend/ERPCore/Controllers/FreeIssuesController.cs b/Backend/ERPCore/Controllers/FreeIssuesController.cs new file mode 100644 index 0000000..93057e8 --- /dev/null +++ b/Backend/ERPCore/Controllers/FreeIssuesController.cs @@ -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; + +/// +/// Thin API alias for free-issue handling. Free issues are modeled as sales slips +/// with line-level IsFreeIssue/FreeQty flags, so this controller reuses +/// the existing sales-slip CRUD surface under a more business-friendly route. +/// +[Route("api/v1/free-issues")] +public sealed class FreeIssuesController : ApiControllerBase +{ + private readonly ISalesSlipService _slips; + + public FreeIssuesController(ISalesSlipService slips) => _slips = slips; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> PostingCheck(int freeIssueId, CancellationToken ct) + => Ok(await _slips.CheckPostingAsync(freeIssueId, ct)); + + [HttpPost] + [ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)] + public async Task> 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> 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> Post(int freeIssueId, CancellationToken ct) + => Ok(await _slips.PostAsync(freeIssueId, ct)); + + [HttpPost("{freeIssueId:int}/cancel")] + [ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)] + public async Task> Cancel(int freeIssueId, CancellationToken ct) + => Ok(await _slips.CancelAsync(freeIssueId, ct)); +} diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs index e27dca0..29eab9e 100644 --- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs +++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs @@ -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> PostingCheck(int salesInvoiceId, CancellationToken ct) + => Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct)); + [HttpPost] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)] public async Task> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct) diff --git a/Backend/ERPCore/Controllers/SalesReportsController.cs b/Backend/ERPCore/Controllers/SalesReportsController.cs index b2edfa8..b6f18c7 100644 --- a/Backend/ERPCore/Controllers/SalesReportsController.cs +++ b/Backend/ERPCore/Controllers/SalesReportsController.cs @@ -11,39 +11,25 @@ public sealed class SalesReportsController : ApiControllerBase public SalesReportsController(ISalesReportService reports) => _reports = reports; - [HttpGet("daily-summary")] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task>> DailySummary( - [FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct) - => Ok(await _reports.DailySummaryAsync(from, to, ct)); + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public ActionResult> ListReports() + => Ok(_reports.ListReports()); - [HttpGet("item-wise")] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task>> ItemWise( - [FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? itemId, [FromQuery] int? warehouseId, CancellationToken ct) - => Ok(await _reports.ItemSummaryAsync(from, to, itemId, warehouseId, ct)); + [HttpGet("{reportId}")] + [ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetReport(string reportId) + { + var report = _reports.GetReport(reportId); + return report is null ? NotFound() : Ok(report); + } - [HttpGet("customer-wise")] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task>> CustomerWise( - [FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? customerId, CancellationToken ct) - => Ok(await _reports.CustomerSummaryAsync(from, to, customerId, ct)); - - [HttpGet("warehouse-wise")] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task>> WarehouseWise( - [FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? warehouseId, CancellationToken ct) - => Ok(await _reports.WarehouseSummaryAsync(from, to, warehouseId, ct)); - - [HttpGet("discount-summary")] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task>> DiscountSummary( - [FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct) - => Ok(await _reports.DiscountSummaryAsync(from, to, ct)); - - [HttpGet("free-issue-summary")] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task>> FreeIssueSummary( - [FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct) - => Ok(await _reports.FreeIssueSummaryAsync(from, to, ct)); + [HttpPost("query")] + [ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)] + public async Task> 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)); + } } diff --git a/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs b/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs new file mode 100644 index 0000000..f5874dc --- /dev/null +++ b/Backend/ERPCore/Controllers/SalesSlipPromotionsController.cs @@ -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> Get(int salesSlipId, CancellationToken ct) + { + var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct); + return result is null ? NotFound() : Ok(result); + } +} \ No newline at end of file diff --git a/Backend/ERPCore/Controllers/SalesSlipsController.cs b/Backend/ERPCore/Controllers/SalesSlipsController.cs index 37601c8..a59f93e 100644 --- a/Backend/ERPCore/Controllers/SalesSlipsController.cs +++ b/Backend/ERPCore/Controllers/SalesSlipsController.cs @@ -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> PostingCheck(int salesSlipId, CancellationToken ct) + => Ok(await _slips.CheckPostingAsync(salesSlipId, ct)); + [HttpPost] [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)] public async Task> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct) diff --git a/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs b/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs index c6d8f79..6f26fb2 100644 --- a/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs +++ b/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs @@ -3,5 +3,5 @@ namespace ERPCore.Domain.Enums; public enum SalesDiscountMode { Percentage = 1, - FixedAmount = 2 + Amount = 2 } diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index 511d891..c541132 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -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 Issues); + public sealed class CreateSalesInvoiceLineRequest { [Required] public int ItemId { get; set; } diff --git a/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs new file mode 100644 index 0000000..dffd1c8 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesPromotionSuggestionDtos.cs @@ -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 RewardOptions); + +public sealed record SalesFreeIssueSuggestionDto( + int SalesSlipId, + string SlipNo, + DateTime SlipDate, + IReadOnlyList Lines); \ No newline at end of file diff --git a/Backend/ERPCore/Dtos/Sales/SalesReportQueryDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReportQueryDtos.cs new file mode 100644 index 0000000..c900df0 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/SalesReportQueryDtos.cs @@ -0,0 +1,21 @@ +namespace ERPCore.Dtos.Sales; + +public sealed record SalesReportDefinitionDto( + string Id, + string Name, + string Description, + IReadOnlyList 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 Rows); diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs index 72270ad..85ec177 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -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 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 Lines); + public sealed class CreateSalesSlipLineRequest { [Required] public int ItemId { get; set; } diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs index a0a987b..c54c918 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs @@ -9,6 +9,7 @@ public interface ISalesInvoiceService { Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default); + Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); Task> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default); Task PostAsync(int salesInvoiceId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesPromotionSuggestionService.cs b/Backend/ERPCore/Services/Interfaces/ISalesPromotionSuggestionService.cs new file mode 100644 index 0000000..97158ec --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalesPromotionSuggestionService.cs @@ -0,0 +1,8 @@ +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface ISalesPromotionSuggestionService +{ + Task GetFreeIssueSuggestionsAsync(int salesSlipId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs index 6e6e591..32fa43d 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs @@ -4,6 +4,9 @@ namespace ERPCore.Services.Interfaces; public interface ISalesReportService { + IReadOnlyList ListReports(); + SalesReportDefinitionDto? GetReport(string reportId); + Task> QueryAsync(string reportType, DateOnly from, DateOnly to, int? itemId, int? customerId, int? warehouseId, CancellationToken ct = default); Task> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default); Task> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default); Task> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs index bd94715..6411b0f 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs @@ -9,6 +9,9 @@ public interface ISalesSlipService { Task> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); Task?> GetAsync(int salesSlipId, CancellationToken ct = default); + Task> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default); + Task?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default); + Task CheckPostingAsync(int salesSlipId, CancellationToken ct = default); Task> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default); Task> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default); Task PostAsync(int salesSlipId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 5f06694..4052aae 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -69,6 +69,41 @@ public sealed class SalesInvoiceService : ISalesInvoiceService return invoice is null ? null : new ETagged(Map(invoice), invoice.RowVersion); } + public async Task 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()); + + var issues = new List(); + 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> 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); diff --git a/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs b/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs new file mode 100644 index 0000000..bdec4a0 --- /dev/null +++ b/Backend/ERPCore/Services/SalesPromotionSuggestionService.cs @@ -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 _slips; + private readonly IRepository _items; + + public SalesPromotionSuggestionService(IRepository slips, IRepository items) + { + _slips = slips; + _items = items; + } + + public async Task 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(); + + 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 + { + 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()) + : new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions); + } +} \ No newline at end of file diff --git a/Backend/ERPCore/Services/SalesReportService.cs b/Backend/ERPCore/Services/SalesReportService.cs index 77e5d08..e931fba 100644 --- a/Backend/ERPCore/Services/SalesReportService.cs +++ b/Backend/ERPCore/Services/SalesReportService.cs @@ -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 _invoices; private readonly IRepository _slips; @@ -18,6 +29,54 @@ public sealed class SalesReportService : ISalesReportService _slips = slips; } + public IReadOnlyList ListReports() => ReportDefinitions; + + public SalesReportDefinitionDto? GetReport(string reportId) + { + var normalized = reportId.Trim().ToLowerInvariant(); + return ReportDefinitions.FirstOrDefault(r => r.Id == normalized); + } + + public async Task> 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().ToList(), + "item" or "item-summary" or "item-wise" => (await ItemSummaryAsync(from, to, itemId, warehouseId, ct)).Cast().ToList(), + "customer" or "customer-summary" or "customer-wise" => (await CustomerSummaryAsync(from, to, customerId, ct)).Cast().ToList(), + "warehouse" or "warehouse-summary" or "warehouse-wise" => (await WarehouseSummaryAsync(from, to, warehouseId, ct)).Cast().ToList(), + "discount" or "discount-summary" => (await DiscountSummaryAsync(from, to, ct)).Cast().ToList(), + "free-issue" or "free-issue-summary" => (await FreeIssueSummaryAsync(from, to, ct)).Cast().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> 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> 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> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default) diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs index 1fa58a2..3d79bdb 100644 --- a/Backend/ERPCore/Services/SalesSlipService.cs +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -70,6 +70,66 @@ public sealed class SalesSlipService : ISalesSlipService return slip is null ? null : new ETagged(Map(slip), slip.RowVersion); } + public async Task> ListFreeIssuesAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable 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.Create(rows.Select(MapFreeIssueSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> 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(MapFreeIssue(slip), slip.RowVersion); + } + + public async Task 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()); + + var issues = new List(); + 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> 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),