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