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> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + if (invoice.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales invoice was modified by another request.", 412); + if (invoice.Status != SalesInvoiceStatus.Draft) + throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be edited."); + + await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.Lines, ct); + invoice.CustomerId = request.CustomerId; + invoice.WarehouseId = request.WarehouseId; + invoice.InvoiceType = request.InvoiceType; + 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.Clear(); + foreach (var line in await BuildLinesAsync(request.Lines, ct)) invoice.Lines.Add(line); + Recalculate(invoice); + invoice.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return new ETagged(Map(invoice), invoice.RowVersion); + } + + public async Task PostAsync(int salesInvoiceId, CancellationToken ct = default) + { + var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + if (invoice.Status != SalesInvoiceStatus.Draft) + throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be posted."); + + var posted = await _uow.ExecuteInTransactionAsync(async token => + { + foreach (var line in invoice.Lines) + { + if (line.Qty <= 0) continue; + var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token); + var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); + await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId, + Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesInvoice), invoice.SalesInvoiceId, DateTime.UtcNow, token); + } + + invoice.Status = SalesInvoiceStatus.Posted; + invoice.UpdatedAt = DateTime.UtcNow; + return invoice; + }, ct); + + return Map(posted); + } + + public async Task CancelAsync(int salesInvoiceId, CancellationToken ct = default) + { + var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); + if (invoice.Status != SalesInvoiceStatus.Draft) + throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled."); + invoice.Status = SalesInvoiceStatus.Cancelled; + invoice.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return Map(invoice); + } + + private async Task ValidateReferencesAsync(int customerId, int warehouseId, List lines, CancellationToken ct) + { + if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) + throw new NotFoundException($"Customer {customerId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct)) + throw new NotFoundException($"Warehouse {warehouseId} was not found."); + foreach (var line in lines) + { + if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct)) + throw new NotFoundException($"Item {line.ItemId} was not found."); + if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct)) + throw new NotFoundException($"UOM {line.UomId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct)) + throw new NotFoundException($"Warehouse {line.WarehouseId} was not found."); + } + } + + private async Task> BuildLinesAsync(List requests, CancellationToken ct) + { + var lines = new List(); + foreach (var r in requests) + { + var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); + var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + var unitPrice = resolved.UnitPrice; + var priceSource = resolved.PriceSource; + + var gross = r.Qty * unitPrice; + var discountTotal = r.IsFreeIssue + ? 0m + : CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount); + var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m; + var lineTotal = gross - discountTotal; + var taxAmount = lineTotal * (r.TaxPct / 100m); + + lines.Add(new SalesInvoiceLine + { + ItemId = r.ItemId, + Description = item.Name, + Qty = r.Qty, + FreeQty = r.FreeQty, + UomId = r.UomId, + WarehouseId = r.WarehouseId, + UnitPrice = unitPrice, + BaseCost = unitPrice, + PriceSource = priceSource, + DiscountPct = r.DiscountPct, + DiscountAmount = discountTotal, + DiscountMode = r.DiscountMode, + NetUnitPrice = netUnit, + LineTotal = lineTotal, + TaxPct = r.TaxPct, + TaxAmount = taxAmount, + IsFreeIssue = r.IsFreeIssue, + ParentLineId = r.ParentLineId + }); + } + return lines; + } + + private static void Recalculate(SalesInvoice invoice) + { + invoice.Subtotal = invoice.Lines.Sum(x => x.Qty * x.UnitPrice); + invoice.DiscountTotal = invoice.Lines.Sum(x => x.DiscountAmount); + invoice.TaxTotal = invoice.Lines.Sum(x => x.TaxAmount); + invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal) + invoice.TaxTotal; + invoice.RoundOff = 0m; + invoice.NetPayable = invoice.GrandTotal + invoice.RoundOff; + invoice.PaidAmount = 0m; + invoice.BalanceAmount = invoice.NetPayable; + } + + private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) + { + var computed = mode == SalesDiscountMode.FixedAmount + ? discountValue + : gross * (discountPct / 100m); + + if (computed <= 0m && legacyDiscountAmount > 0m) + computed = legacyDiscountAmount; + + return Math.Min(gross, Math.Max(0m, computed)); + } + + private static SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( + x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, + x.WarehouseId, x.InvoiceType, x.Status, new SalesInvoiceTotalsDto( + x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), x.CreatedAt); + + private static SalesInvoiceDto Map(SalesInvoice x) => new( + x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.CustomerSnapshotTaxNo, + x.WarehouseId, x.InvoiceType, x.Status, x.CreatedBy, x.CreatedAt, x.UpdatedAt, + new SalesInvoiceTotalsDto(x.Subtotal, x.DiscountTotal, x.Lines.Sum(l => l.FreeQty), x.TaxTotal, x.GrandTotal, x.RoundOff, x.NetPayable, x.PaidAmount, x.BalanceAmount), + x.Lines.Select(l => new SalesInvoiceLineDto(l.SalesInvoiceLineId, 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()); +} diff --git a/Backend/ERPCore/Services/SalesPricingService.cs b/Backend/ERPCore/Services/SalesPricingService.cs new file mode 100644 index 0000000..b8d09b4 --- /dev/null +++ b/Backend/ERPCore/Services/SalesPricingService.cs @@ -0,0 +1,79 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class SalesPricingService : ISalesPricingService +{ + private readonly IRepository _items; + private readonly IRepository _grnLines; + private readonly IFifoCostingService _fifo; + + public SalesPricingService(IRepository items, IRepository grnLines, IFifoCostingService fifo) + { + _items = items; + _grnLines = grnLines; + _fifo = fifo; + } + + public async Task ResolveAsync( + int itemId, int warehouseId, decimal? requestedUnitPrice, bool allowManualOverride, CancellationToken ct = default) + { + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(x => x.ItemId == itemId, ct) + ?? throw new InvalidOperationException($"Item {itemId} was not found."); + + if (requestedUnitPrice is not null) + { + if (!allowManualOverride) + { + if (item.SalePrice.HasValue) + return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); + + var grnPrice = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); + if (grnPrice is not null) + return new SalesPriceResolution(grnPrice.Value, "GRN_WEIGHTED_AVG", grnPrice.Value); + + return new SalesPriceResolution(await GetFifoFallbackPriceAsync(itemId, warehouseId, ct), "FIFO_AVG", 0m); + } + + return new SalesPriceResolution(requestedUnitPrice.Value, "MANUAL", requestedUnitPrice.Value); + } + + if (item.SalePrice.HasValue) + return new SalesPriceResolution(item.SalePrice.Value, "SALE_PRICE", item.SalePrice.Value); + + var weighted = await GetWeightedGrnPriceAsync(itemId, warehouseId, ct); + if (weighted is not null) + return new SalesPriceResolution(weighted.Value, "GRN_WEIGHTED_AVG", weighted.Value); + + return new SalesPriceResolution(await GetFifoFallbackPriceAsync(itemId, warehouseId, ct), "FIFO_AVG", 0m); + } + + private async Task GetWeightedGrnPriceAsync(int itemId, int warehouseId, CancellationToken ct) + { + var rows = await _grnLines.Query().AsNoTracking() + .Where(l => l.ItemId == itemId + && l.Grn != null + && l.Grn.WarehouseId == warehouseId + && l.Grn.Status == GrnStatus.Confirmed) + .Select(l => new { l.Qty, l.ReceivedValue }) + .ToListAsync(ct); + + var totalQty = rows.Sum(x => x.Qty); + if (totalQty <= 0) return null; + + var totalValue = rows.Sum(x => x.ReceivedValue); + return totalValue / totalQty; + } + + private async Task GetFifoFallbackPriceAsync(int itemId, int warehouseId, CancellationToken ct) + { + var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct); + return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m; + } +} diff --git a/Backend/ERPCore/Services/SalesReportService.cs b/Backend/ERPCore/Services/SalesReportService.cs new file mode 100644 index 0000000..77e5d08 --- /dev/null +++ b/Backend/ERPCore/Services/SalesReportService.cs @@ -0,0 +1,297 @@ +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 SalesReportService : ISalesReportService +{ + private readonly IRepository _invoices; + private readonly IRepository _slips; + + public SalesReportService(IRepository invoices, IRepository slips) + { + _invoices = invoices; + _slips = slips; + } + + public async Task> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + 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)) + .GroupBy(x => DateOnly.FromDateTime(x.InvoiceDate)) + .Select(g => new + { + Date = g.Key, + InvoiceCount = g.Count(), + SlipCount = 0, + InvoiceSubtotal = g.Sum(x => x.Subtotal), + SlipSubtotal = 0m, + DiscountTotal = g.Sum(x => x.DiscountTotal), + FreeQtyTotal = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + TaxTotal = g.Sum(x => x.TaxTotal), + GrandTotal = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + 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)) + .GroupBy(x => DateOnly.FromDateTime(x.SlipDate)) + .Select(g => new + { + Date = g.Key, + InvoiceCount = 0, + SlipCount = g.Count(), + InvoiceSubtotal = 0m, + SlipSubtotal = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + FreeQtyTotal = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + TaxTotal = g.Sum(x => x.TaxTotal), + GrandTotal = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => x.Date) + .OrderBy(x => x.Key) + .Select(g => new SalesDailySummaryRowDto( + g.Key, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.InvoiceSubtotal), + g.Sum(x => x.SlipSubtotal), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.FreeQtyTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.GrandTotal))) + .ToList(); + } + + 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 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); + + var rows = await invoiceQuery.Concat(slipQuery) + .GroupBy(x => new { x.ItemId, x.Description }) + .Select(g => new SalesItemSummaryRowDto( + g.Key.ItemId, + g.Key.Description, + g.Sum(x => x.Qty), + g.Sum(x => x.FreeQty), + 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; + } + + public async Task> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default) + { + var invoiceQuery = _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (customerId is not null) invoiceQuery = invoiceQuery.Where(x => x.CustomerId == customerId); + + var slipQuery = _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue)); + if (customerId is not null) slipQuery = slipQuery.Where(x => x.CustomerId == customerId); + + var invoiceRows = await invoiceQuery + .GroupBy(x => new { x.CustomerId, x.CustomerSnapshotName }) + .Select(g => new + { + g.Key.CustomerId, + CustomerName = g.Key.CustomerSnapshotName, + InvoiceCount = g.Count(), + SlipCount = 0, + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + var slipRows = await slipQuery + .GroupBy(x => new { x.CustomerId, x.CustomerSnapshotName }) + .Select(g => new + { + g.Key.CustomerId, + CustomerName = g.Key.CustomerSnapshotName, + InvoiceCount = 0, + SlipCount = g.Count(), + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => new { x.CustomerId, x.CustomerName }) + .OrderByDescending(g => g.Sum(x => x.NetAmount)) + .Select(g => new SalesCustomerSummaryRowDto( + g.Key.CustomerId, + g.Key.CustomerName, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.SoldQty), + g.Sum(x => x.FreeQty), + g.Sum(x => x.GrossAmount), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.NetAmount))) + .ToList(); + } + + public async Task> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default) + { + var invoiceQuery = _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) invoiceQuery = invoiceQuery.Where(x => x.WarehouseId == warehouseId); + + var slipQuery = _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) slipQuery = slipQuery.Where(x => x.WarehouseId == warehouseId); + + var invoiceRows = await invoiceQuery + .GroupBy(x => new { x.WarehouseId, x.Warehouse!.Name }) + .Select(g => new + { + g.Key.WarehouseId, + WarehouseName = g.Key.Name, + InvoiceCount = g.Count(), + SlipCount = 0, + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + var slipRows = await slipQuery + .GroupBy(x => new { x.WarehouseId, x.Warehouse!.Name }) + .Select(g => new + { + g.Key.WarehouseId, + WarehouseName = g.Key.Name, + InvoiceCount = 0, + SlipCount = g.Count(), + SoldQty = g.Sum(x => x.Lines.Sum(l => l.Qty)), + FreeQty = g.Sum(x => x.Lines.Sum(l => l.FreeQty)), + GrossAmount = g.Sum(x => x.Subtotal), + DiscountTotal = g.Sum(x => x.DiscountTotal), + TaxTotal = g.Sum(x => x.TaxTotal), + NetAmount = g.Sum(x => x.GrandTotal) + }) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows) + .GroupBy(x => new { x.WarehouseId, x.WarehouseName }) + .OrderByDescending(g => g.Sum(x => x.NetAmount)) + .Select(g => new SalesWarehouseSummaryRowDto( + g.Key.WarehouseId, + g.Key.WarehouseName, + g.Sum(x => x.InvoiceCount), + g.Sum(x => x.SlipCount), + g.Sum(x => x.SoldQty), + g.Sum(x => x.FreeQty), + g.Sum(x => x.GrossAmount), + g.Sum(x => x.DiscountTotal), + g.Sum(x => x.TaxTotal), + g.Sum(x => x.NetAmount))) + .ToList(); + } + + public async Task> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + var invoices = await _invoices.Query().AsNoTracking() + .Where(x => x.Status == SalesInvoiceStatus.Posted && x.InvoiceDate >= from.ToDateTime(TimeOnly.MinValue) && x.InvoiceDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue) && x.DiscountTotal > 0m) + .Select(x => new SalesDiscountSummaryRowDto("Invoice", x.InvoiceNo, x.InvoiceDate, x.CustomerSnapshotName, x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal)) + .ToListAsync(ct); + + var slips = await _slips.Query().AsNoTracking() + .Where(x => x.Status == SalesSlipStatus.Posted && x.SlipDate >= from.ToDateTime(TimeOnly.MinValue) && x.SlipDate < to.AddDays(1).ToDateTime(TimeOnly.MinValue) && x.DiscountTotal > 0m) + .Select(x => new SalesDiscountSummaryRowDto("Slip", x.SlipNo, x.SlipDate, x.CustomerSnapshotName, x.Subtotal, x.DiscountTotal, x.TaxTotal, x.GrandTotal)) + .ToListAsync(ct); + + return invoices.Concat(slips).OrderByDescending(x => x.DiscountTotal).ToList(); + } + + public async Task> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + 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.Where(l => l.FreeQty > 0m).Select(l => new SalesFreeIssueSummaryRowDto( + "Invoice", + x.InvoiceNo, + x.InvoiceDate, + x.CustomerSnapshotName, + l.ItemId, + l.Description, + l.FreeQty, + l.FreeQty * l.UnitPrice, + l.WarehouseId, + x.Warehouse!.Name))) + .ToListAsync(ct); + + 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.Where(l => l.FreeQty > 0m).Select(l => new SalesFreeIssueSummaryRowDto( + "Slip", + x.SlipNo, + x.SlipDate, + x.CustomerSnapshotName, + l.ItemId, + l.Description, + l.FreeQty, + l.FreeQty * l.UnitPrice, + l.WarehouseId, + x.Warehouse!.Name))) + .ToListAsync(ct); + + return invoiceRows.Concat(slipRows).OrderByDescending(x => x.FreeQty).ToList(); + } +} diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs new file mode 100644 index 0000000..1fa58a2 --- /dev/null +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -0,0 +1,248 @@ +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 SalesSlipService : ISalesSlipService +{ + private readonly IRepository _slips; + private readonly IRepository _customers; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly IRepository _users; + private readonly ISalesPricingService _pricing; + private readonly IFifoCostingService _fifo; + private readonly ICurrentUser _currentUser; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public SalesSlipService( + IRepository slips, IRepository customers, IRepository items, + IRepository uoms, IRepository warehouses, IRepository users, ISalesPricingService pricing, IFifoCostingService fifo, + ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) + { + _slips = slips; + _customers = customers; + _items = items; + _uoms = uoms; + _warehouses = warehouses; + _users = users; + _pricing = pricing; + _fifo = fifo; + _currentUser = currentUser; + _numbers = numbers; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable q = _slips.Query().AsNoTracking().Include(x => x.Lines); + 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(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(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(Map(slip), slip.RowVersion); + } + + public async Task> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default) + { + await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct); + + var slip = new SalesSlip + { + SlipNo = await _numbers.NextAsync(DocumentTypes.SalesSlip, ct), + SlipDate = DateTime.UtcNow, + CustomerId = request.CustomerId, + WarehouseId = request.WarehouseId, + CashierUserId = request.CashierUserId, + Status = SalesSlipStatus.Draft, + CreatedAt = DateTime.UtcNow + }; + slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + slip.Lines = await BuildLinesAsync(request.Lines, ct); + Recalculate(slip); + + await _slips.AddAsync(slip, ct); + await _uow.SaveChangesAsync(ct); + return new ETagged(Map(slip), slip.RowVersion); + } + + public async Task> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The sales slip was modified by another request.", 412); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be edited."); + + await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.Lines, ct); + slip.CustomerId = request.CustomerId; + slip.WarehouseId = request.WarehouseId; + slip.CashierUserId = request.CashierUserId; + slip.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); + slip.Lines.Clear(); + foreach (var line in await BuildLinesAsync(request.Lines, ct)) slip.Lines.Add(line); + Recalculate(slip); + slip.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return new ETagged(Map(slip), slip.RowVersion); + } + + public async Task PostAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be posted."); + + var posted = await _uow.ExecuteInTransactionAsync(async token => + { + foreach (var line in slip.Lines) + { + if (line.Qty <= 0 && line.FreeQty <= 0) continue; + var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty + line.FreeQty, token); + var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); + await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId, + Direction.Out, line.Qty + line.FreeQty, cost, 0m, nameof(SalesSlip), slip.SalesSlipId, DateTime.UtcNow, token); + } + + slip.Status = SalesSlipStatus.Posted; + slip.UpdatedAt = DateTime.UtcNow; + return slip; + }, ct); + + return Map(posted); + } + + public async Task CancelAsync(int salesSlipId, CancellationToken ct = default) + { + var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); + if (slip.Status != SalesSlipStatus.Draft) + throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled."); + slip.Status = SalesSlipStatus.Cancelled; + slip.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return Map(slip); + } + + private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, List lines, CancellationToken ct) + { + if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct)) + throw new NotFoundException($"Customer {customerId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct)) + throw new NotFoundException($"Warehouse {warehouseId} was not found."); + if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct)) + throw new NotFoundException($"User {cashierUserId} was not found."); + foreach (var line in lines) + { + if (!await _items.Query().AnyAsync(x => x.ItemId == line.ItemId, ct)) + throw new NotFoundException($"Item {line.ItemId} was not found."); + if (!await _uoms.Query().AnyAsync(x => x.UomId == line.UomId, ct)) + throw new NotFoundException($"UOM {line.UomId} was not found."); + if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == line.WarehouseId, ct)) + throw new NotFoundException($"Warehouse {line.WarehouseId} was not found."); + } + } + + private async Task> BuildLinesAsync(List requests, CancellationToken ct) + { + var lines = new List(); + foreach (var r in requests) + { + var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); + var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); + var unitPrice = resolved.UnitPrice; + var priceSource = resolved.PriceSource; + + var gross = r.Qty * unitPrice; + var discountTotal = r.IsFreeIssue + ? 0m + : CalculateDiscount(gross, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount); + var netUnit = r.Qty > 0 ? (gross - discountTotal) / r.Qty : 0m; + var lineTotal = gross - discountTotal; + var taxAmount = lineTotal * (r.TaxPct / 100m); + + lines.Add(new SalesSlipLine + { + ItemId = r.ItemId, + Description = item.Name, + Qty = r.Qty, + FreeQty = r.FreeQty, + UomId = r.UomId, + WarehouseId = r.WarehouseId, + UnitPrice = unitPrice, + BaseCost = unitPrice, + PriceSource = priceSource, + DiscountMode = r.DiscountMode, + DiscountPct = r.DiscountPct, + DiscountAmount = discountTotal, + NetUnitPrice = netUnit, + LineTotal = lineTotal, + TaxPct = r.TaxPct, + TaxAmount = taxAmount, + IsFreeIssue = r.IsFreeIssue, + ParentLineId = r.ParentLineId + }); + } + return lines; + } + + private static void Recalculate(SalesSlip slip) + { + slip.Subtotal = slip.Lines.Sum(x => x.Qty * x.UnitPrice); + slip.DiscountTotal = slip.Lines.Sum(x => x.DiscountAmount); + slip.TaxTotal = slip.Lines.Sum(x => x.TaxAmount); + slip.GrandTotal = slip.Lines.Sum(x => x.LineTotal) + slip.TaxTotal; + slip.PaidAmount = 0m; + slip.BalanceAmount = slip.GrandTotal - slip.PaidAmount; + } + + private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) + { + var computed = mode == SalesDiscountMode.FixedAmount + ? discountValue + : gross * (discountPct / 100m); + + if (computed <= 0m && legacyDiscountAmount > 0m) + computed = legacyDiscountAmount; + + return Math.Min(gross, Math.Max(0m, computed)); + } + + private static SalesSlipSummaryDto MapSummary(SalesSlip x) => new( + 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 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), + 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()); +} diff --git a/docs/SALES_MODULE_PLAN.md b/docs/SALES_MODULE_PLAN.md new file mode 100644 index 0000000..efb4bea --- /dev/null +++ b/docs/SALES_MODULE_PLAN.md @@ -0,0 +1,383 @@ +# Sales Module Plan + +## Summary +This sales module is split into two phases: +- **Phase 1**: basic, standard sales features that fit the current ERP architecture +- **Phase 2**: enterprise extensions that can be added after the core flow is stable + +The design stays aligned with the existing backend patterns: +- controller thinness +- service-based business rules +- repository + unit of work +- ETag concurrency +- audit logging +- stock FIFO and ledger posting + +Returns, credit notes, and sales returns are **out of scope for Phase 1**. + +--- + +## Phase 1 - Basic Standard Sales Module + +### Goal +Implement the minimum sales flow needed for both B2B and B2C: +- maintain customers +- create sales invoices +- create sales slips +- support fixed sale price fallback and GRN-based cost fallback +- support discounts by percentage and value +- support free issue lines +- post stock movement and ledger entries +- generate basic sales reports + +### In Scope +- Customer master +- Sales invoice +- Sales invoice lines +- Sales slip +- Sales slip lines +- Pricing resolver +- Discount calculation +- Free issue handling +- Stock posting +- Basic sales reports + +### Not in Scope for Phase 1 +- customer groups +- price lists +- promotions +- reservations +- sales payments allocation +- approval workflow +- returns and credit notes +- advanced customer segmentation + +### Phase 1 Entity Design + +#### `Customer` +Basic customer master used for both B2B and B2C. + +Fields: +- `CustomerId` +- `CustomerCode` +- `CustomerType` (`B2B`, `B2C`, `WalkIn`) +- `Name` +- `DisplayName` +- `Phone` +- `Email` +- `AddressLine1` +- `AddressLine2` +- `City` +- `Country` +- `TaxRegistrationNo` +- `CreditLimit` +- `CreditDays` +- `DefaultWarehouseId` +- `Status` +- `CreatedAt` +- `UpdatedAt` +- `RowVersion` + +#### `SalesInvoice` +Primary posted sales document. + +Fields: +- `SalesInvoiceId` +- `InvoiceNo` +- `InvoiceDate` +- `CustomerId` +- `CustomerSnapshotName` +- `CustomerSnapshotTaxNo` +- `WarehouseId` +- `InvoiceType` (`B2B`, `B2C`, `Cash`, `Credit`) +- `Status` (`Draft`, `Posted`, `Cancelled`) +- `Subtotal` +- `DiscountTotal` +- `TaxTotal` +- `GrandTotal` +- `RoundOff` +- `NetPayable` +- `PaidAmount` +- `BalanceAmount` +- `CreatedBy` +- `CreatedAt` +- `UpdatedAt` +- `RowVersion` + +#### `SalesInvoiceLine` +Invoice line with pricing, discount, and free issue support. + +Fields: +- `SalesInvoiceLineId` +- `SalesInvoiceId` +- `ItemId` +- `Description` +- `Qty` +- `FreeQty` +- `UomId` +- `WarehouseId` +- `UnitPrice` +- `BaseCost` +- `PriceSource` +- `DiscountPct` +- `DiscountAmount` +- `NetUnitPrice` +- `LineTotal` +- `TaxPct` +- `TaxAmount` +- `IsFreeIssue` +- `ParentLineId` +- `RowVersion` + +#### `SalesSlip` +Fast retail or counter-sale document. + +Fields: +- `SalesSlipId` +- `SlipNo` +- `SlipDate` +- `CustomerId` +- `CustomerSnapshotName` +- `WarehouseId` +- `CashierUserId` +- `Status` +- `Subtotal` +- `DiscountTotal` +- `TaxTotal` +- `GrandTotal` +- `PaidAmount` +- `BalanceAmount` +- `CreatedAt` +- `UpdatedAt` +- `RowVersion` + +#### `SalesSlipLine` +Slip line with the same sales calculation rules as invoices. + +Fields: +- `SalesSlipLineId` +- `SalesSlipId` +- `ItemId` +- `Description` +- `Qty` +- `FreeQty` +- `UomId` +- `WarehouseId` +- `UnitPrice` +- `BaseCost` +- `PriceSource` +- `DiscountPct` +- `DiscountAmount` +- `NetUnitPrice` +- `LineTotal` +- `TaxPct` +- `TaxAmount` +- `IsFreeIssue` +- `ParentLineId` +- `RowVersion` + +### Phase 1 Pricing Rule +Use the following order: +1. fixed `Item.SalePrice` +2. GRN-derived stock cost fallback +3. FIFO valuation fallback + +Important: +- use a weighted average when deriving from multiple GRNs +- keep the resolved source in `PriceSource` +- allow manual override only if permitted by business rule + +### Phase 1 Discount Rule +Support: +- percentage discount +- fixed value discount + +Discount must be computed server-side and stored in line and document totals. + +### Phase 1 Free Issue Rule +Support free issue lines in the same invoice/slip document. + +Rules: +- free quantity must be separate from paid quantity +- free issue still reduces stock +- free issue must be visible in reports +- free issue should not be merged into discount + +### Phase 1 Stock Posting Rule +When an invoice or slip is posted: +- reduce stock from the selected warehouse +- consume FIFO layers +- write `StockLedger` rows +- maintain source document traceability +- update totals in the same transaction + +### Phase 1 API Route List +- `GET /api/v1/customers` +- `GET /api/v1/customers/{id}` +- `POST /api/v1/customers` +- `PUT /api/v1/customers/{id}` +- `PATCH /api/v1/customers/{id}/status` +- `GET /api/v1/sales-invoices` +- `GET /api/v1/sales-invoices/{id}` +- `POST /api/v1/sales-invoices` +- `PUT /api/v1/sales-invoices/{id}` +- `POST /api/v1/sales-invoices/{id}/post` +- `POST /api/v1/sales-invoices/{id}/cancel` +- `GET /api/v1/sales-invoices/{id}/print-preview` +- `GET /api/v1/sales-slips` +- `GET /api/v1/sales-slips/{id}` +- `POST /api/v1/sales-slips` +- `POST /api/v1/sales-slips/{id}/post` +- `POST /api/v1/sales-slips/{id}/cancel` +- `GET /api/v1/sales-reports/daily-summary` +- `GET /api/v1/sales-reports/item-wise` +- `GET /api/v1/sales-reports/customer-wise` +- `GET /api/v1/sales-reports/warehouse-wise` +- `GET /api/v1/sales-reports/discount-summary` +- `GET /api/v1/sales-reports/free-issue-summary` +- `GET /api/v1/sales-reports/margin-summary` + +### Phase 1 Folder / Module Plan +- `Domain/Entities` + - add `Customer`, `SalesInvoice`, `SalesInvoiceLine`, `SalesSlip`, `SalesSlipLine` +- `Domain/Enums` + - add sales status enums and invoice/slip type enums +- `Dtos/Sales` + - add request and response DTOs for customer, invoice, slip, and reports +- `Services/Interfaces` + - add `ICustomerService`, `ISalesInvoiceService`, `ISalesSlipService`, `ISalesPricingService`, `ISalesReportService` +- `Services` + - implement the sales services with transaction-safe logic +- `Controllers` + - add `CustomersController`, `SalesInvoicesController`, `SalesSlipsController`, `SalesReportsController` +- `Infra/Persistence/Configurations` + - add EF Core mappings for all sales entities +- `Infra/Persistence/ErpDbContext.cs` + - register sales `DbSet`s +- `Infra/Persistence/Migrations` + - add the sales schema migration after the model is defined + +### Phase 1 Implementation Order +1. Customer master +2. Sales invoice entity and DTOs +3. Sales slip entity and DTOs +4. Pricing resolver +5. Discount computation +6. Free issue handling +7. Stock posting and ledger integration +8. Basic sales reports +9. Controllers and swagger wiring + +--- + +## Phase 2 - Enterprise Extensions + +### Goal +Add richer commercial features after Phase 1 is stable and tested. + +### In Scope +- customer groups +- price lists +- promotions +- free issue schemes +- reservations +- payment allocation +- approval flow +- advanced reporting dimensions + +### Phase 2 Entity Additions + +#### `CustomerGroup` +Used only if group-level pricing or segmentation is needed. + +#### `PriceList` +Customer, group, warehouse, or global price policies. + +#### `PriceListItem` +Per-item pricing rows inside a price list. + +#### `Promotion` +Promotional header. + +#### `PromotionRule` +Buy-X-get-Y, discount, or reward rules. + +#### `FreeIssueScheme` +Separate free issue header. + +#### `FreeIssueSchemeLine` +Rule lines for free issue behavior. + +#### `SalesReservation` +Stock reservation header for B2B order fulfillment. + +#### `SalesReservationLine` +Reserved item quantities. + +#### `SalesPayment` +Payment header for cash or credit settlement. + +#### `SalesPaymentAllocation` +Allocation of a payment across invoices. + +### Phase 2 API Routes +- `GET /api/v1/customer-groups` +- `POST /api/v1/customer-groups` +- `PUT /api/v1/customer-groups/{id}` +- `PATCH /api/v1/customer-groups/{id}/status` +- `GET /api/v1/price-lists` +- `POST /api/v1/price-lists` +- `PUT /api/v1/price-lists/{id}` +- `PATCH /api/v1/price-lists/{id}/status` +- `GET /api/v1/price-lists/{id}/items` +- `PUT /api/v1/price-lists/{id}/items` +- `GET /api/v1/pricing/resolve` +- `GET /api/v1/promotions` +- `POST /api/v1/promotions` +- `PUT /api/v1/promotions/{id}` +- `PATCH /api/v1/promotions/{id}/status` +- `GET /api/v1/free-issue-schemes` +- `POST /api/v1/free-issue-schemes` +- `PUT /api/v1/free-issue-schemes/{id}` +- `PATCH /api/v1/free-issue-schemes/{id}/status` +- `POST /api/v1/sales-orders` +- `POST /api/v1/sales-orders/{id}/reserve` +- `POST /api/v1/sales-orders/{id}/confirm` +- `POST /api/v1/sales-payments` +- `POST /api/v1/sales-payments/{id}/allocate` +- extended reporting routes for channel, cashier, tax, and credit views + +### Phase 2 Folder / Module Plan +- extend the same sales folders rather than creating a separate module +- add new entities and DTOs under the same sales namespace +- add new service interfaces and service implementations next to Phase 1 sales services +- add new controllers only for the advanced routes +- add migrations incrementally so Phase 1 tables remain stable + +### Phase 2 Implementation Order +1. customer groups +2. price lists +3. promotions and free issue schemes +4. sales orders and reservations +5. payments and allocations +6. advanced reports +7. permissions and approval workflow + +--- + +## Test Plan +- Verify customer CRUD with ETag concurrency and status changes. +- Verify invoice and slip create/update/post flows. +- Verify fixed sale price fallback works. +- Verify GRN-derived fallback uses weighted average. +- Verify discounts calculate correctly by percentage and fixed value. +- Verify free issue lines post stock and appear in reports. +- Verify stock ledger entries are created once per posted document. +- Verify Phase 1 routes remain stable before Phase 2 is added. + +## Assumptions +- Phase 1 is intentionally minimal and should not include customer groups or price lists. +- `SalesInvoice` is the primary posted sales document. +- `SalesSlip` is a simplified retail document. +- Returns are deferred to a later step. +- Existing ERP patterns must be preserved: repository, unit of work, audit, ETag, and FIFO stock posting.