diff --git a/Backend/ERPCore/Controllers/CustomersController.cs b/Backend/ERPCore/Controllers/CustomersController.cs
new file mode 100644
index 0000000..ff7cda4
--- /dev/null
+++ b/Backend/ERPCore/Controllers/CustomersController.cs
@@ -0,0 +1,69 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Customers;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Customer master endpoints for Phase 1 sales.
+[Route("api/v1/customers")]
+public sealed class CustomersController : ApiControllerBase
+{
+ private readonly ICustomerService _customers;
+
+ public CustomersController(ICustomerService customers) => _customers = customers;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query,
+ [FromQuery] EntityStatus? status,
+ [FromQuery] CustomerType? customerType,
+ CancellationToken ct)
+ => Ok(await _customers.ListAsync(query, status, customerType, ct));
+
+ [HttpGet("{customerId:int}")]
+ [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int customerId, CancellationToken ct)
+ {
+ var result = await _customers.GetAsync(customerId, ct);
+ if (result is null) return NotFound();
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
+ {
+ var result = await _customers.CreateAsync(request, ct);
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
+ }
+
+ [HttpPut("{customerId:int}")]
+ [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _customers.UpdateAsync(customerId, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPatch("{customerId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
+ {
+ await _customers.SetStatusAsync(customerId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
new file mode 100644
index 0000000..e27dca0
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs
@@ -0,0 +1,67 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Sales;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/sales-invoices")]
+public sealed class SalesInvoicesController : ApiControllerBase
+{
+ private readonly ISalesInvoiceService _invoices;
+
+ public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query,
+ [FromQuery] SalesInvoiceStatus? status,
+ [FromQuery] int? customerId,
+ [FromQuery] int? warehouseId,
+ CancellationToken ct)
+ => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
+
+ [HttpGet("{salesInvoiceId:int}")]
+ [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int salesInvoiceId, CancellationToken ct)
+ {
+ var result = await _invoices.GetAsync(salesInvoiceId, ct);
+ if (result is null) return NotFound();
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
+ public async Task> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
+ {
+ var result = await _invoices.CreateAsync(request, ct);
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
+ }
+
+ [HttpPut("{salesInvoiceId:int}")]
+ [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost("{salesInvoiceId:int}/post")]
+ [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
+ public async Task> Post(int salesInvoiceId, CancellationToken ct)
+ => Ok(await _invoices.PostAsync(salesInvoiceId, ct));
+
+ [HttpPost("{salesInvoiceId:int}/cancel")]
+ [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
+ public async Task> Cancel(int salesInvoiceId, CancellationToken ct)
+ => Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/SalesReportsController.cs b/Backend/ERPCore/Controllers/SalesReportsController.cs
new file mode 100644
index 0000000..b2edfa8
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SalesReportsController.cs
@@ -0,0 +1,49 @@
+using ERPCore.Dtos.Sales;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/reports/sales")]
+public sealed class SalesReportsController : ApiControllerBase
+{
+ private readonly ISalesReportService _reports;
+
+ 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("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("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));
+}
diff --git a/Backend/ERPCore/Controllers/SalesSlipsController.cs b/Backend/ERPCore/Controllers/SalesSlipsController.cs
new file mode 100644
index 0000000..37601c8
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SalesSlipsController.cs
@@ -0,0 +1,67 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Sales;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/sales-slips")]
+public sealed class SalesSlipsController : ApiControllerBase
+{
+ private readonly ISalesSlipService _slips;
+
+ public SalesSlipsController(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.ListAsync(query, status, customerId, warehouseId, ct));
+
+ [HttpGet("{salesSlipId:int}")]
+ [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int salesSlipId, CancellationToken ct)
+ {
+ var result = await _slips.GetAsync(salesSlipId, ct);
+ if (result is null) return NotFound();
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(SalesSlipDto), 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/sales-slips/{result.Value.SalesSlipId}", result.Value);
+ }
+
+ [HttpPut("{salesSlipId:int}")]
+ [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost("{salesSlipId:int}/post")]
+ [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
+ public async Task> Post(int salesSlipId, CancellationToken ct)
+ => Ok(await _slips.PostAsync(salesSlipId, ct));
+
+ [HttpPost("{salesSlipId:int}/cancel")]
+ [ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
+ public async Task> Cancel(int salesSlipId, CancellationToken ct)
+ => Ok(await _slips.CancelAsync(salesSlipId, ct));
+}
diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs
index 564e516..4e1f4ee 100644
--- a/Backend/ERPCore/Domain/DocumentTypes.cs
+++ b/Backend/ERPCore/Domain/DocumentTypes.cs
@@ -17,4 +17,6 @@ public static class DocumentTypes
/// Production run (docs/30 FR-MFG-08) — PRD-2026-00001.
public const string Production = "PRD";
+ public const string SalesInvoice = "SI";
+ public const string SalesSlip = "SSL";
}
diff --git a/Backend/ERPCore/Domain/Entities/Customer.cs b/Backend/ERPCore/Domain/Entities/Customer.cs
new file mode 100644
index 0000000..916a1b9
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Customer.cs
@@ -0,0 +1,38 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Customer master for both B2B and B2C sales.
+/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
+///
+public class Customer
+{
+ public int CustomerId { get; set; }
+ public string CustomerCode { get; set; } = string.Empty;
+ public CustomerType CustomerType { get; set; } = CustomerType.B2C;
+
+ public string Name { get; set; } = string.Empty;
+ public string? DisplayName { get; set; }
+ public string? Phone { get; set; }
+ public string? Email { get; set; }
+
+ public string? AddressLine1 { get; set; }
+ public string? AddressLine2 { get; set; }
+ public string? City { get; set; }
+ public string? Country { get; set; }
+
+ public string? TaxRegistrationNo { get; set; }
+ public decimal CreditLimit { get; set; }
+ public int CreditDays { get; set; }
+
+ public int? DefaultWarehouseId { get; set; }
+ public Warehouse? DefaultWarehouse { get; set; }
+
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoice.cs b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs
new file mode 100644
index 0000000..15f40de
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoice.cs
@@ -0,0 +1,41 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+public class SalesInvoice
+{
+ public int SalesInvoiceId { get; set; }
+ public string InvoiceNo { get; set; } = string.Empty;
+ public DateTime InvoiceDate { get; set; }
+
+ public int CustomerId { get; set; }
+ public Customer? Customer { get; set; }
+
+ public string CustomerSnapshotName { get; set; } = string.Empty;
+ public string? CustomerSnapshotTaxNo { get; set; }
+
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
+ public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
+
+ public decimal Subtotal { get; set; }
+ public decimal DiscountTotal { get; set; }
+ public decimal TaxTotal { get; set; }
+ public decimal GrandTotal { get; set; }
+ public decimal RoundOff { get; set; }
+ public decimal NetPayable { get; set; }
+ public decimal PaidAmount { get; set; }
+ public decimal BalanceAmount { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
new file mode 100644
index 0000000..e7e4beb
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
@@ -0,0 +1,38 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+public class SalesInvoiceLine
+{
+ public int SalesInvoiceLineId { get; set; }
+
+ public int SalesInvoiceId { get; set; }
+ public SalesInvoice? SalesInvoice { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string Description { get; set; } = string.Empty;
+
+ public decimal Qty { get; set; }
+ public decimal FreeQty { get; set; }
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public decimal UnitPrice { get; set; }
+ public decimal BaseCost { get; set; }
+ public string PriceSource { get; set; } = string.Empty;
+ public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
+ public decimal DiscountPct { get; set; }
+ public decimal DiscountAmount { get; set; }
+ public decimal NetUnitPrice { get; set; }
+ public decimal LineTotal { get; set; }
+ public decimal TaxPct { get; set; }
+ public decimal TaxAmount { get; set; }
+ public bool IsFreeIssue { get; set; }
+ public int? ParentLineId { get; set; }
+
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesSlip.cs b/Backend/ERPCore/Domain/Entities/SalesSlip.cs
new file mode 100644
index 0000000..46f697a
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesSlip.cs
@@ -0,0 +1,36 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+public class SalesSlip
+{
+ public int SalesSlipId { get; set; }
+ public string SlipNo { get; set; } = string.Empty;
+ public DateTime SlipDate { get; set; }
+
+ public int CustomerId { get; set; }
+ public Customer? Customer { get; set; }
+
+ public string CustomerSnapshotName { get; set; } = string.Empty;
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public int CashierUserId { get; set; }
+ public User? CashierUser { get; set; }
+
+ public SalesSlipStatus Status { get; set; } = SalesSlipStatus.Draft;
+
+ public decimal Subtotal { get; set; }
+ public decimal DiscountTotal { get; set; }
+ public decimal TaxTotal { get; set; }
+ public decimal GrandTotal { get; set; }
+ public decimal PaidAmount { get; set; }
+ public decimal BalanceAmount { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
new file mode 100644
index 0000000..0eb3de2
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
@@ -0,0 +1,38 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+public class SalesSlipLine
+{
+ public int SalesSlipLineId { get; set; }
+
+ public int SalesSlipId { get; set; }
+ public SalesSlip? SalesSlip { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string Description { get; set; } = string.Empty;
+
+ public decimal Qty { get; set; }
+ public decimal FreeQty { get; set; }
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public decimal UnitPrice { get; set; }
+ public decimal BaseCost { get; set; }
+ public string PriceSource { get; set; } = string.Empty;
+ public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
+ public decimal DiscountPct { get; set; }
+ public decimal DiscountAmount { get; set; }
+ public decimal NetUnitPrice { get; set; }
+ public decimal LineTotal { get; set; }
+ public decimal TaxPct { get; set; }
+ public decimal TaxAmount { get; set; }
+ public bool IsFreeIssue { get; set; }
+ public int? ParentLineId { get; set; }
+
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Enums/CustomerType.cs b/Backend/ERPCore/Domain/Enums/CustomerType.cs
new file mode 100644
index 0000000..5928ba5
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/CustomerType.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum CustomerType
+{
+ B2B = 1,
+ B2C = 2,
+ WalkIn = 3
+}
diff --git a/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs b/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs
new file mode 100644
index 0000000..c6d8f79
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SalesDiscountMode.cs
@@ -0,0 +1,7 @@
+namespace ERPCore.Domain.Enums;
+
+public enum SalesDiscountMode
+{
+ Percentage = 1,
+ FixedAmount = 2
+}
diff --git a/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs b/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs
new file mode 100644
index 0000000..b3c4aab
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SalesInvoiceStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum SalesInvoiceStatus
+{
+ Draft = 1,
+ Posted = 2,
+ Cancelled = 3
+}
diff --git a/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs b/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs
new file mode 100644
index 0000000..375c497
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SalesInvoiceType.cs
@@ -0,0 +1,9 @@
+namespace ERPCore.Domain.Enums;
+
+public enum SalesInvoiceType
+{
+ B2B = 1,
+ B2C = 2,
+ Cash = 3,
+ Credit = 4
+}
diff --git a/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs b/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs
new file mode 100644
index 0000000..400d11f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SalesSlipStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum SalesSlipStatus
+{
+ Draft = 1,
+ Posted = 2,
+ Cancelled = 3
+}
diff --git a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
new file mode 100644
index 0000000..3b7175d
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
@@ -0,0 +1,66 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Customers;
+
+/// Customer resource used by sales documents.
+public sealed record CustomerDto(
+ int CustomerId,
+ string CustomerCode,
+ CustomerType CustomerType,
+ string Name,
+ string? DisplayName,
+ string? Phone,
+ string? Email,
+ string? AddressLine1,
+ string? AddressLine2,
+ string? City,
+ string? Country,
+ string? TaxRegistrationNo,
+ decimal CreditLimit,
+ int CreditDays,
+ int? DefaultWarehouseId,
+ EntityStatus Status,
+ DateTime CreatedAt,
+ DateTime? UpdatedAt);
+
+public sealed class CreateCustomerRequest
+{
+ [Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
+ [Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
+ [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [StringLength(200)] public string? DisplayName { get; set; }
+ [StringLength(30)] public string? Phone { get; set; }
+ [StringLength(100)] public string? Email { get; set; }
+ [StringLength(250)] public string? AddressLine1 { get; set; }
+ [StringLength(250)] public string? AddressLine2 { get; set; }
+ [StringLength(100)] public string? City { get; set; }
+ [StringLength(100)] public string? Country { get; set; }
+ [StringLength(50)] public string? TaxRegistrationNo { get; set; }
+ [Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
+ [Range(0, int.MaxValue)] public int CreditDays { get; set; }
+ public int? DefaultWarehouseId { get; set; }
+}
+
+public sealed class UpdateCustomerRequest
+{
+ [Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
+ [Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
+ [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [StringLength(200)] public string? DisplayName { get; set; }
+ [StringLength(30)] public string? Phone { get; set; }
+ [StringLength(100)] public string? Email { get; set; }
+ [StringLength(250)] public string? AddressLine1 { get; set; }
+ [StringLength(250)] public string? AddressLine2 { get; set; }
+ [StringLength(100)] public string? City { get; set; }
+ [StringLength(100)] public string? Country { get; set; }
+ [StringLength(50)] public string? TaxRegistrationNo { get; set; }
+ [Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
+ [Range(0, int.MaxValue)] public int CreditDays { get; set; }
+ public int? DefaultWarehouseId { get; set; }
+}
+
+public sealed class UpdateCustomerStatusRequest
+{
+ [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
new file mode 100644
index 0000000..511d891
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs
@@ -0,0 +1,59 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Sales;
+
+public sealed record SalesInvoiceLineDto(
+ int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
+ int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
+ decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
+ decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
+
+public sealed record SalesInvoiceTotalsDto(
+ decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
+ decimal RoundOff, decimal NetPayable, decimal PaidAmount, decimal BalanceAmount);
+
+public sealed record SalesInvoiceDto(
+ int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
+ string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
+ SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
+ DateTime? UpdatedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList Lines);
+
+public sealed record SalesInvoiceSummaryDto(
+ int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
+ string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
+ SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt);
+
+public sealed class CreateSalesInvoiceLineRequest
+{
+ [Required] public int ItemId { get; set; }
+ [Required] public int UomId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
+ [Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
+ [Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
+ public bool AllowManualPriceOverride { get; set; }
+ [Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
+ [Range(0, 100)] public decimal DiscountPct { get; set; }
+ [Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
+ [Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
+ [Range(0, 100)] public decimal TaxPct { get; set; }
+ public bool IsFreeIssue { get; set; }
+ public int? ParentLineId { get; set; }
+}
+
+public sealed class CreateSalesInvoiceRequest
+{
+ [Required] public int CustomerId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
+
+public sealed class UpdateSalesInvoiceRequest
+{
+ [Required] public int CustomerId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs
new file mode 100644
index 0000000..bea474c
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Sales/SalesReportDtos.cs
@@ -0,0 +1,68 @@
+namespace ERPCore.Dtos.Sales;
+
+public sealed record SalesDailySummaryRowDto(
+ DateOnly Date,
+ int InvoiceCount,
+ int SlipCount,
+ decimal InvoiceSubtotal,
+ decimal SlipSubtotal,
+ decimal DiscountTotal,
+ decimal FreeQtyTotal,
+ decimal TaxTotal,
+ decimal GrandTotal);
+
+public sealed record SalesItemSummaryRowDto(
+ int ItemId,
+ string ItemName,
+ decimal SoldQty,
+ decimal FreeQty,
+ decimal GrossAmount,
+ decimal DiscountTotal,
+ decimal TaxTotal,
+ decimal NetAmount);
+
+public sealed record SalesCustomerSummaryRowDto(
+ int CustomerId,
+ string CustomerName,
+ int InvoiceCount,
+ int SlipCount,
+ decimal SoldQty,
+ decimal FreeQty,
+ decimal GrossAmount,
+ decimal DiscountTotal,
+ decimal TaxTotal,
+ decimal NetAmount);
+
+public sealed record SalesWarehouseSummaryRowDto(
+ int WarehouseId,
+ string WarehouseName,
+ int InvoiceCount,
+ int SlipCount,
+ decimal SoldQty,
+ decimal FreeQty,
+ decimal GrossAmount,
+ decimal DiscountTotal,
+ decimal TaxTotal,
+ decimal NetAmount);
+
+public sealed record SalesDiscountSummaryRowDto(
+ string DocumentType,
+ string DocumentNo,
+ DateTime DocumentDate,
+ string CustomerName,
+ decimal Subtotal,
+ decimal DiscountTotal,
+ decimal TaxTotal,
+ decimal NetAmount);
+
+public sealed record SalesFreeIssueSummaryRowDto(
+ string DocumentType,
+ string DocumentNo,
+ DateTime DocumentDate,
+ string CustomerName,
+ int ItemId,
+ string ItemName,
+ decimal FreeQty,
+ decimal FreeValue,
+ int WarehouseId,
+ string WarehouseName);
diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
new file mode 100644
index 0000000..72270ad
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs
@@ -0,0 +1,58 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Sales;
+
+public sealed record SalesSlipLineDto(
+ int SalesSlipLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int UomId,
+ int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
+ decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
+ decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
+
+public sealed record SalesSlipTotalsDto(
+ decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
+ decimal PaidAmount, decimal BalanceAmount);
+
+public sealed record SalesSlipDto(
+ int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
+ string CustomerSnapshotName, int WarehouseId, int CashierUserId, SalesSlipStatus Status,
+ DateTime CreatedAt, DateTime? UpdatedAt, SalesSlipTotalsDto Totals, IReadOnlyList Lines);
+
+public sealed record SalesSlipSummaryDto(
+ int SalesSlipId, string SlipNo, DateTime SlipDate, int CustomerId,
+ string CustomerSnapshotName, int WarehouseId, SalesSlipStatus Status,
+ SalesSlipTotalsDto Totals, DateTime CreatedAt);
+
+public sealed class CreateSalesSlipLineRequest
+{
+ [Required] public int ItemId { get; set; }
+ [Required] public int UomId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
+ [Range(0, double.MaxValue)] public decimal FreeQty { get; set; }
+ [Range(0, double.MaxValue)] public decimal? UnitPrice { get; set; }
+ public bool AllowManualPriceOverride { get; set; }
+ [Required, EnumDataType(typeof(SalesDiscountMode))] public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
+ [Range(0, 100)] public decimal DiscountPct { get; set; }
+ [Range(0, double.MaxValue)] public decimal DiscountAmount { get; set; }
+ [Range(0, double.MaxValue)] public decimal DiscountValue { get; set; }
+ [Range(0, 100)] public decimal TaxPct { get; set; }
+ public bool IsFreeIssue { get; set; }
+ public int? ParentLineId { get; set; }
+}
+
+public sealed class CreateSalesSlipRequest
+{
+ [Required] public int CustomerId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ [Required] public int CashierUserId { get; set; }
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
+
+public sealed class UpdateSalesSlipRequest
+{
+ [Required] public int CustomerId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ [Required] public int CashierUserId { get; set; }
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs
new file mode 100644
index 0000000..a52b327
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs
@@ -0,0 +1,50 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class CustomerConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("customers");
+ builder.HasKey(c => c.CustomerId);
+
+ builder.Property(c => c.CustomerCode).IsRequired().HasMaxLength(50);
+ builder.HasIndex(c => c.CustomerCode).IsUnique();
+
+ builder.Property(c => c.CustomerType)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(CustomerType.B2C);
+
+ builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
+ builder.Property(c => c.DisplayName).HasMaxLength(200);
+ builder.Property(c => c.Phone).HasMaxLength(30);
+ builder.Property(c => c.Email).HasMaxLength(100);
+ builder.Property(c => c.AddressLine1).HasMaxLength(250);
+ builder.Property(c => c.AddressLine2).HasMaxLength(250);
+ builder.Property(c => c.City).HasMaxLength(100);
+ builder.Property(c => c.Country).HasMaxLength(100);
+ builder.Property(c => c.TaxRegistrationNo).HasMaxLength(50);
+
+ builder.Property(c => c.CreditLimit).HasPrecision(18, 4);
+ builder.Property(c => c.CreditDays).IsRequired();
+
+ builder.HasOne(c => c.DefaultWarehouse)
+ .WithMany()
+ .HasForeignKey(c => c.DefaultWarehouseId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.Property(c => c.Status)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(EntityStatus.Active);
+
+ builder.Property(c => c.CreatedAt).IsRequired();
+ builder.Property(c => c.RowVersion).IsRowVersion();
+
+ builder.HasIndex(c => c.Status);
+ builder.HasIndex(c => c.CustomerType);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
new file mode 100644
index 0000000..e8f27ed
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs
@@ -0,0 +1,95 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("sales_invoices");
+ builder.HasKey(x => x.SalesInvoiceId);
+
+ builder.Property(x => x.InvoiceNo).IsRequired().HasMaxLength(50);
+ builder.HasIndex(x => x.InvoiceNo).IsUnique();
+
+ builder.Property(x => x.InvoiceDate).IsRequired();
+
+ builder.HasOne(x => x.Customer)
+ .WithMany()
+ .HasForeignKey(x => x.CustomerId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
+ builder.Property(x => x.CustomerSnapshotTaxNo).HasMaxLength(50);
+
+ builder.HasOne(x => x.Warehouse)
+ .WithMany()
+ .HasForeignKey(x => x.WarehouseId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.InvoiceType)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(SalesInvoiceType.B2C);
+
+ builder.Property(x => x.Status)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(SalesInvoiceStatus.Draft);
+
+ foreach (var p in new[] { nameof(SalesInvoice.Subtotal), nameof(SalesInvoice.DiscountTotal), nameof(SalesInvoice.TaxTotal), nameof(SalesInvoice.GrandTotal), nameof(SalesInvoice.RoundOff), nameof(SalesInvoice.NetPayable), nameof(SalesInvoice.PaidAmount), nameof(SalesInvoice.BalanceAmount) })
+ builder.Property(p).HasPrecision(18, 4);
+
+ builder.Property(x => x.CreatedAt).IsRequired();
+ builder.Property(x => x.RowVersion).IsRowVersion();
+
+ builder.HasIndex(x => x.Status);
+ builder.HasIndex(x => x.InvoiceDate);
+
+ builder.HasMany(x => x.Lines)
+ .WithOne(x => x.SalesInvoice)
+ .HasForeignKey(x => x.SalesInvoiceId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("sales_invoice_lines");
+ builder.HasKey(x => x.SalesInvoiceLineId);
+
+ builder.HasOne(x => x.Item)
+ .WithMany()
+ .HasForeignKey(x => x.ItemId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
+
+ builder.Property(x => x.Qty).HasPrecision(18, 4);
+ builder.Property(x => x.FreeQty).HasPrecision(18, 4);
+ builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
+ builder.Property(x => x.BaseCost).HasPrecision(18, 4);
+ builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
+ builder.Property(x => x.DiscountAmount).HasPrecision(18, 4);
+ builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4);
+ builder.Property(x => x.LineTotal).HasPrecision(18, 4);
+ builder.Property(x => x.TaxPct).HasPrecision(9, 4);
+ builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
+
+ builder.HasOne(x => x.Uom)
+ .WithMany()
+ .HasForeignKey(x => x.UomId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(x => x.Warehouse)
+ .WithMany()
+ .HasForeignKey(x => x.WarehouseId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.PriceSource).HasMaxLength(50);
+ builder.Property(x => x.RowVersion).IsRowVersion();
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
new file mode 100644
index 0000000..deeeed0
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesSlipConfiguration.cs
@@ -0,0 +1,95 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class SalesSlipConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("sales_slips");
+ builder.HasKey(x => x.SalesSlipId);
+
+ builder.Property(x => x.SlipNo).IsRequired().HasMaxLength(50);
+ builder.HasIndex(x => x.SlipNo).IsUnique();
+
+ builder.Property(x => x.SlipDate).IsRequired();
+
+ builder.HasOne(x => x.Customer)
+ .WithMany()
+ .HasForeignKey(x => x.CustomerId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
+
+ builder.HasOne(x => x.Warehouse)
+ .WithMany()
+ .HasForeignKey(x => x.WarehouseId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(x => x.CashierUser)
+ .WithMany()
+ .HasForeignKey(x => x.CashierUserId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.Status)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(SalesSlipStatus.Draft);
+
+ foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) })
+ builder.Property(p).HasPrecision(18, 4);
+
+ builder.Property(x => x.CreatedAt).IsRequired();
+ builder.Property(x => x.RowVersion).IsRowVersion();
+
+ builder.HasIndex(x => x.Status);
+ builder.HasIndex(x => x.SlipDate);
+
+ builder.HasMany(x => x.Lines)
+ .WithOne(x => x.SalesSlip)
+ .HasForeignKey(x => x.SalesSlipId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("sales_slip_lines");
+ builder.HasKey(x => x.SalesSlipLineId);
+
+ builder.HasOne(x => x.Item)
+ .WithMany()
+ .HasForeignKey(x => x.ItemId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
+
+ builder.Property(x => x.Qty).HasPrecision(18, 4);
+ builder.Property(x => x.FreeQty).HasPrecision(18, 4);
+ builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
+ builder.Property(x => x.BaseCost).HasPrecision(18, 4);
+ builder.Property(x => x.DiscountPct).HasPrecision(9, 4);
+ builder.Property(x => x.DiscountAmount).HasPrecision(18, 4);
+ builder.Property(x => x.NetUnitPrice).HasPrecision(18, 4);
+ builder.Property(x => x.LineTotal).HasPrecision(18, 4);
+ builder.Property(x => x.TaxPct).HasPrecision(9, 4);
+ builder.Property(x => x.TaxAmount).HasPrecision(18, 4);
+
+ builder.HasOne(x => x.Uom)
+ .WithMany()
+ .HasForeignKey(x => x.UomId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(x => x.Warehouse)
+ .WithMany()
+ .HasForeignKey(x => x.WarehouseId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Property(x => x.PriceSource).HasMaxLength(50);
+ builder.Property(x => x.RowVersion).IsRowVersion();
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index ca895cd..ec71934 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -22,6 +22,7 @@ public class ErpDbContext : DbContext
}
// --- Master Data (docs/10 Part C.1) ---
+ public DbSet Customers => Set();
public DbSet Categories => Set();
public DbSet SubCategories => Set();
public DbSet Brands => Set();
@@ -82,6 +83,12 @@ public class ErpDbContext : DbContext
public DbSet PurchaseReturns => Set();
public DbSet PurchaseReturnLines => Set();
+ // --- Sales (Phase 1) ---
+ public DbSet SalesInvoices => Set();
+ public DbSet SalesInvoiceLines => Set();
+ public DbSet SalesSlips => Set();
+ public DbSet SalesSlipLines => Set();
+
// --- Reference data (docs/10 Part C.7) ---
public DbSet ReasonCodes => Set();
diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs
index c484971..2247216 100644
--- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs
+++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs
@@ -407,6 +407,106 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("categories", (string)null);
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
+ {
+ b.Property("CustomerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CustomerId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreditDays")
+ .HasColumnType("integer");
+
+ b.Property("CreditLimit")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CustomerCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CustomerType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("B2C");
+
+ b.Property("DefaultWarehouseId")
+ .HasColumnType("integer");
+
+ b.Property("DisplayName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Email")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Phone")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TaxRegistrationNo")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CustomerId");
+
+ b.HasIndex("CustomerCode")
+ .IsUnique();
+
+ b.HasIndex("CustomerType");
+
+ b.HasIndex("DefaultWarehouseId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("customers", (string)null);
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
{
b.Property("DepartmentId")
@@ -3212,6 +3312,406 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("hr_salary_components", (string)null);
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
+ {
+ b.Property("SalesInvoiceId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SalesInvoiceId"));
+
+ b.Property("BalanceAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("CreatorUserId")
+ .HasColumnType("integer");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("CustomerSnapshotTaxNo")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("InvoiceDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InvoiceNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("InvoiceType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("B2C");
+
+ b.Property("NetPayable")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("PaidAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RoundOff")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("Subtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("SalesInvoiceId");
+
+ b.HasIndex("CreatorUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("InvoiceDate");
+
+ b.HasIndex("InvoiceNo")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("sales_invoices", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
+ {
+ b.Property("SalesInvoiceLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SalesInvoiceLineId"));
+
+ b.Property("BaseCost")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DiscountAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("DiscountMode")
+ .HasColumnType("integer");
+
+ b.Property("DiscountPct")
+ .HasPrecision(9, 4)
+ .HasColumnType("numeric(9,4)");
+
+ b.Property("FreeQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("IsFreeIssue")
+ .HasColumnType("boolean");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("NetUnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("integer");
+
+ b.Property("PriceSource")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SalesInvoiceId")
+ .HasColumnType("integer");
+
+ b.Property("TaxAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("TaxPct")
+ .HasPrecision(9, 4)
+ .HasColumnType("numeric(9,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("SalesInvoiceLineId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("SalesInvoiceId");
+
+ b.HasIndex("UomId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("sales_invoice_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
+ {
+ b.Property("SalesSlipId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SalesSlipId"));
+
+ b.Property("BalanceAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CashierUserId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("PaidAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SlipDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SlipNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("Subtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("SalesSlipId");
+
+ b.HasIndex("CashierUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("SlipDate");
+
+ b.HasIndex("SlipNo")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("sales_slips", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b =>
+ {
+ b.Property("SalesSlipLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SalesSlipLineId"));
+
+ b.Property("BaseCost")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DiscountAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("DiscountMode")
+ .HasColumnType("integer");
+
+ b.Property("DiscountPct")
+ .HasPrecision(9, 4)
+ .HasColumnType("numeric(9,4)");
+
+ b.Property("FreeQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("IsFreeIssue")
+ .HasColumnType("boolean");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("NetUnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("integer");
+
+ b.Property("PriceSource")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SalesSlipId")
+ .HasColumnType("integer");
+
+ b.Property("TaxAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("TaxPct")
+ .HasPrecision(9, 4)
+ .HasColumnType("numeric(9,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("SalesSlipLineId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("SalesSlipId");
+
+ b.HasIndex("UomId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("sales_slip_lines", (string)null);
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{
b.Property("SerialId")
@@ -4459,6 +4959,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
+ .WithMany()
+ .HasForeignKey("DefaultWarehouseId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("DefaultWarehouse");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
@@ -5282,6 +5792,128 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Uom");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.User", "Creator")
+ .WithMany()
+ .HasForeignKey("CreatorUserId");
+
+ b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
+ .WithMany()
+ .HasForeignKey("WarehouseId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Creator");
+
+ b.Navigation("Customer");
+
+ b.Navigation("Warehouse");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.Item", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice")
+ .WithMany("Lines")
+ .HasForeignKey("SalesInvoiceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
+ .WithMany()
+ .HasForeignKey("UomId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
+ .WithMany()
+ .HasForeignKey("WarehouseId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("SalesInvoice");
+
+ b.Navigation("Uom");
+
+ b.Navigation("Warehouse");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
+ .WithMany()
+ .HasForeignKey("CashierUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
+ .WithMany()
+ .HasForeignKey("WarehouseId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("CashierUser");
+
+ b.Navigation("Customer");
+
+ b.Navigation("Warehouse");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b =>
+ {
+ b.HasOne("ERPCore.Domain.Entities.Item", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.SalesSlip", "SalesSlip")
+ .WithMany("Lines")
+ .HasForeignKey("SalesSlipId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
+ .WithMany()
+ .HasForeignKey("UomId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
+ .WithMany()
+ .HasForeignKey("WarehouseId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("SalesSlip");
+
+ b.Navigation("Uom");
+
+ b.Navigation("Warehouse");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -5824,6 +6456,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Outputs");
});
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
+ {
+ b.Navigation("Lines");
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
+ {
+ b.Navigation("Lines");
+ });
+
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.Navigation("Lines");
diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs
index 1a40914..c89a8c3 100644
--- a/Backend/ERPCore/Program.cs
+++ b/Backend/ERPCore/Program.cs
@@ -7,6 +7,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
+using ERPCore.Services.Interfaces;
using ERPCore.Services.Auth;
using ERPCore.Services.Hrm;
using ERPCore.Services.Interfaces;
@@ -62,6 +63,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 §2)
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -87,6 +89,12 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+// Sales (Phase 1)
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
// Stock transactions + reference data (docs/11 §5–6)
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/Backend/ERPCore/Services/CustomerService.cs b/Backend/ERPCore/Services/CustomerService.cs
new file mode 100644
index 0000000..0f7c64e
--- /dev/null
+++ b/Backend/ERPCore/Services/CustomerService.cs
@@ -0,0 +1,168 @@
+using ERPCore.Common.Http;
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Customers;
+using ERPCore.Infra.UoW;
+using ERPCore.Repositories.Interfaces;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+using Microsoft.EntityFrameworkCore;
+
+namespace ERPCore.Services;
+
+public sealed class CustomerService : ICustomerService
+{
+ private readonly IRepository _customers;
+ private readonly IRepository _warehouses;
+ private readonly IUnitOfWork _uow;
+
+ public CustomerService(IRepository customers, IRepository warehouses, IUnitOfWork uow)
+ {
+ _customers = customers;
+ _warehouses = warehouses;
+ _uow = uow;
+ }
+
+ public async Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
+ {
+ var q = _customers.Query().AsNoTracking();
+ if (!string.IsNullOrWhiteSpace(query.Q))
+ {
+ var term = query.Q.Trim();
+ q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%")
+ || EF.Functions.ILike(c.CustomerCode, $"%{term}%")
+ || (c.DisplayName != null && EF.Functions.ILike(c.DisplayName, $"%{term}%")));
+ }
+ if (status is not null) q = q.Where(c => c.Status == status);
+ if (customerType is not null) q = q.Where(c => c.CustomerType == customerType);
+
+ var total = await q.CountAsync(ct);
+ var rows = await q.OrderBy(c => c.Name)
+ .Skip(query.Skip).Take(query.PageSize)
+ .Select(c => new CustomerDto(
+ c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
+ c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
+ c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt))
+ .ToListAsync(ct);
+
+ return PagedResponse.Create(rows, query.Page, query.PageSize, total);
+ }
+
+ public async Task?> GetAsync(int customerId, CancellationToken ct = default)
+ {
+ var customer = await _customers.Query().AsNoTracking()
+ .FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
+ return customer is null ? null : new ETagged(Map(customer), customer.RowVersion);
+ }
+
+ public async Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default)
+ {
+ var code = request.CustomerCode.Trim();
+ var name = request.Name.Trim();
+
+ if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct))
+ throw new ConflictException($"A customer code '{code}' already exists.");
+
+ if (!string.IsNullOrWhiteSpace(request.Email))
+ {
+ var email = request.Email.Trim();
+ if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct))
+ throw new ConflictException($"A customer with email '{email}' already exists.");
+ }
+
+ if (request.DefaultWarehouseId is not null)
+ {
+ var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
+ if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
+ }
+
+ var customer = new Customer
+ {
+ CustomerCode = code,
+ CustomerType = request.CustomerType,
+ Name = name,
+ DisplayName = Normalize(request.DisplayName),
+ Phone = Normalize(request.Phone),
+ Email = Normalize(request.Email),
+ AddressLine1 = Normalize(request.AddressLine1),
+ AddressLine2 = Normalize(request.AddressLine2),
+ City = Normalize(request.City),
+ Country = Normalize(request.Country),
+ TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
+ CreditLimit = request.CreditLimit,
+ CreditDays = request.CreditDays,
+ DefaultWarehouseId = request.DefaultWarehouseId,
+ Status = EntityStatus.Active,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ await _customers.AddAsync(customer, ct);
+ await _uow.SaveChangesAsync(ct);
+
+ return new ETagged(Map(customer), customer.RowVersion);
+ }
+
+ public async Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default)
+ {
+ var customer = await _customers.GetByIdAsync(customerId, ct)
+ ?? throw new NotFoundException($"Customer {customerId} was not found.");
+
+ if (customer.RowVersion != expectedRowVersion)
+ throw new DomainException(ErrorCodes.ConcurrencyConflict, "The customer was modified by another request.", 412);
+
+ var code = request.CustomerCode.Trim();
+ var name = request.Name.Trim();
+
+ if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal)
+ && await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct))
+ throw new ConflictException($"A customer code '{code}' already exists.");
+
+ if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(request.Email)
+ && await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct))
+ throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists.");
+
+ if (request.DefaultWarehouseId is not null)
+ {
+ var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
+ if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
+ }
+
+ customer.CustomerCode = code;
+ customer.CustomerType = request.CustomerType;
+ customer.Name = name;
+ customer.DisplayName = Normalize(request.DisplayName);
+ customer.Phone = Normalize(request.Phone);
+ customer.Email = Normalize(request.Email);
+ customer.AddressLine1 = Normalize(request.AddressLine1);
+ customer.AddressLine2 = Normalize(request.AddressLine2);
+ customer.City = Normalize(request.City);
+ customer.Country = Normalize(request.Country);
+ customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
+ customer.CreditLimit = request.CreditLimit;
+ customer.CreditDays = request.CreditDays;
+ customer.DefaultWarehouseId = request.DefaultWarehouseId;
+ customer.UpdatedAt = DateTime.UtcNow;
+
+ await _uow.SaveChangesAsync(ct);
+ return new ETagged(Map(customer), customer.RowVersion);
+ }
+
+ public async Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default)
+ {
+ var customer = await _customers.GetByIdAsync(customerId, ct)
+ ?? throw new NotFoundException($"Customer {customerId} was not found.");
+
+ customer.Status = status;
+ customer.UpdatedAt = DateTime.UtcNow;
+ await _uow.SaveChangesAsync(ct);
+ }
+
+ private static CustomerDto Map(Customer c) => new(
+ c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
+ c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
+ c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt);
+
+ private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+}
diff --git a/Backend/ERPCore/Services/Interfaces/ICustomerService.cs b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs
new file mode 100644
index 0000000..47e837c
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs
@@ -0,0 +1,15 @@
+using ERPCore.Common.Http;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Customers;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Services.Interfaces;
+
+public interface ICustomerService
+{
+ Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
+ Task?> GetAsync(int customerId, CancellationToken ct = default);
+ Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
+ Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
+ Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default);
+}
diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs
new file mode 100644
index 0000000..a0a987b
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs
@@ -0,0 +1,16 @@
+using ERPCore.Common.Http;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Sales;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Services.Interfaces;
+
+public interface ISalesInvoiceService
+{
+ Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
+ Task?> GetAsync(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);
+ Task CancelAsync(int salesInvoiceId, CancellationToken ct = default);
+}
diff --git a/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs b/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs
new file mode 100644
index 0000000..87617e4
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/ISalesPricingService.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Services.Interfaces;
+
+public interface ISalesPricingService
+{
+ Task ResolveAsync(
+ int itemId,
+ int warehouseId,
+ decimal? requestedUnitPrice,
+ bool allowManualOverride,
+ CancellationToken ct = default);
+}
+
+public sealed record SalesPriceResolution(
+ decimal UnitPrice,
+ string PriceSource,
+ decimal BaseCost);
diff --git a/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs
new file mode 100644
index 0000000..6e6e591
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/ISalesReportService.cs
@@ -0,0 +1,13 @@
+using ERPCore.Dtos.Sales;
+
+namespace ERPCore.Services.Interfaces;
+
+public interface ISalesReportService
+{
+ 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);
+ Task> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default);
+ Task> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
+ Task> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
+}
diff --git a/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs
new file mode 100644
index 0000000..bd94715
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/ISalesSlipService.cs
@@ -0,0 +1,16 @@
+using ERPCore.Common.Http;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Sales;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Services.Interfaces;
+
+public interface ISalesSlipService
+{
+ Task> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
+ Task?> GetAsync(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);
+ Task CancelAsync(int salesSlipId, CancellationToken ct = default);
+}
diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs
new file mode 100644
index 0000000..5f06694
--- /dev/null
+++ b/Backend/ERPCore/Services/SalesInvoiceService.cs
@@ -0,0 +1,251 @@
+using ERPCore.Common.Http;
+using ERPCore.Domain;
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Sales;
+using ERPCore.Infra.Auth;
+using ERPCore.Infra.UoW;
+using ERPCore.Repositories.Interfaces;
+using ERPCore.Services.Interfaces;
+using ERPCore.Services.Stock;
+using ERPCore.System.Errors;
+using Microsoft.EntityFrameworkCore;
+
+namespace ERPCore.Services;
+
+public sealed class SalesInvoiceService : ISalesInvoiceService
+{
+ private readonly IRepository _invoices;
+ private readonly IRepository _customers;
+ private readonly IRepository- _items;
+ private readonly IRepository _uoms;
+ private readonly IRepository _warehouses;
+ private readonly ISalesPricingService _pricing;
+ private readonly IFifoCostingService _fifo;
+ private readonly ICurrentUser _currentUser;
+ private readonly INumberSequenceService _numbers;
+ private readonly IUnitOfWork _uow;
+
+ public SalesInvoiceService(
+ IRepository invoices, IRepository customers, IRepository
- items,
+ IRepository uoms, IRepository warehouses, ISalesPricingService pricing, IFifoCostingService fifo,
+ ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
+ {
+ _invoices = invoices;
+ _customers = customers;
+ _items = items;
+ _uoms = uoms;
+ _warehouses = warehouses;
+ _pricing = pricing;
+ _fifo = fifo;
+ _currentUser = currentUser;
+ _numbers = numbers;
+ _uow = uow;
+ }
+
+ public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
+ {
+ IQueryable q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
+ if (!string.IsNullOrWhiteSpace(query.Q))
+ {
+ var term = query.Q.Trim();
+ q = q.Where(x => EF.Functions.ILike(x.InvoiceNo, $"%{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.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
+
+ return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
+ }
+
+ public async Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
+ {
+ var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
+ .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
+ return invoice is null ? null : new ETagged(Map(invoice), invoice.RowVersion);
+ }
+
+ public async Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
+ {
+ await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct);
+ var invoice = new SalesInvoice
+ {
+ InvoiceNo = await _numbers.NextAsync(DocumentTypes.SalesInvoice, ct),
+ InvoiceDate = DateTime.UtcNow,
+ CustomerId = request.CustomerId,
+ WarehouseId = request.WarehouseId,
+ InvoiceType = request.InvoiceType,
+ Status = SalesInvoiceStatus.Draft,
+ CreatedBy = _currentUser.AuditUserId,
+ CreatedAt = DateTime.UtcNow
+ };
+ invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
+ invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
+ invoice.Lines = await BuildLinesAsync(request.Lines, ct);
+ Recalculate(invoice);
+
+ await _invoices.AddAsync(invoice, ct);
+ await _uow.SaveChangesAsync(ct);
+ return new ETagged(Map(invoice), invoice.RowVersion);
+ }
+
+ public async Task