Merge pull request 'Feat/sales management' (#23) from feat/sales-management into Dev
Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||||
|
[Route("api/v1/customers")]
|
||||||
|
public sealed class CustomersController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ICustomerService _customers;
|
||||||
|
|
||||||
|
public CustomersController(ICustomerService customers) => _customers = customers;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<CustomerDto>>> 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<ActionResult<CustomerDto>> 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<ActionResult<CustomerDto>> 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<ActionResult<CustomerDto>> 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<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _customers.SetStatusAsync(customerId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> 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<ActionResult<SalesInvoiceDto>> 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<ActionResult<SalesInvoiceDto>> 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<ActionResult<SalesInvoiceDto>> 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<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
|
||||||
|
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{salesInvoiceId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
|
||||||
|
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
|
||||||
|
}
|
||||||
@@ -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<SalesDailySummaryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesDailySummaryRowDto>>> DailySummary(
|
||||||
|
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.DailySummaryAsync(from, to, ct));
|
||||||
|
|
||||||
|
[HttpGet("item-wise")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<SalesItemSummaryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesItemSummaryRowDto>>> 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<SalesCustomerSummaryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesCustomerSummaryRowDto>>> 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<SalesWarehouseSummaryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesWarehouseSummaryRowDto>>> 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<SalesDiscountSummaryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesDiscountSummaryRowDto>>> DiscountSummary(
|
||||||
|
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.DiscountSummaryAsync(from, to, ct));
|
||||||
|
|
||||||
|
[HttpGet("free-issue-summary")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<SalesFreeIssueSummaryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SalesFreeIssueSummaryRowDto>>> FreeIssueSummary(
|
||||||
|
[FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.FreeIssueSummaryAsync(from, to, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> 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<ActionResult<SalesSlipDto>> 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<ActionResult<SalesSlipDto>> 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<ActionResult<SalesSlipDto>> 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<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.PostAsync(salesSlipId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{salesSlipId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
|
||||||
|
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
|
||||||
|
}
|
||||||
@@ -17,4 +17,6 @@ public static class DocumentTypes
|
|||||||
|
|
||||||
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
||||||
public const string Production = "PRD";
|
public const string Production = "PRD";
|
||||||
|
public const string SalesInvoice = "SI";
|
||||||
|
public const string SalesSlip = "SSL";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Customer master for both B2B and B2C sales.
|
||||||
|
/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -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<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<SalesSlipLine> Lines { get; set; } = new List<SalesSlipLine>();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum CustomerType
|
||||||
|
{
|
||||||
|
B2B = 1,
|
||||||
|
B2C = 2,
|
||||||
|
WalkIn = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum SalesDiscountMode
|
||||||
|
{
|
||||||
|
Percentage = 1,
|
||||||
|
FixedAmount = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum SalesInvoiceStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Posted = 2,
|
||||||
|
Cancelled = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum SalesInvoiceType
|
||||||
|
{
|
||||||
|
B2B = 1,
|
||||||
|
B2C = 2,
|
||||||
|
Cash = 3,
|
||||||
|
Credit = 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum SalesSlipStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Posted = 2,
|
||||||
|
Cancelled = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Customers;
|
||||||
|
|
||||||
|
/// <summary>Customer resource used by sales documents.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -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<SalesInvoiceLineDto> 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<CreateSalesInvoiceLineRequest> 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<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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<SalesSlipLineDto> 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<CreateSalesSlipLineRequest> 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<CreateSalesSlipLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -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<Customer>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Customer> 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<string>().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<string>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SalesInvoice>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesInvoice> 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<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(SalesInvoiceType.B2C);
|
||||||
|
|
||||||
|
builder.Property(x => x.Status)
|
||||||
|
.HasConversion<string>().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<decimal>(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<SalesInvoiceLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesInvoiceLine> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SalesSlip>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesSlip> 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<string>().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<decimal>(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<SalesSlipLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SalesSlipLine> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ public class ErpDbContext : DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Master Data (docs/10 Part C.1) ---
|
// --- Master Data (docs/10 Part C.1) ---
|
||||||
|
public DbSet<Customer> Customers => Set<Customer>();
|
||||||
public DbSet<Category> Categories => Set<Category>();
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
||||||
public DbSet<Brand> Brands => Set<Brand>();
|
public DbSet<Brand> Brands => Set<Brand>();
|
||||||
@@ -82,6 +83,12 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
|
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
|
||||||
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
|
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
|
||||||
|
|
||||||
|
// --- Sales (Phase 1) ---
|
||||||
|
public DbSet<SalesInvoice> SalesInvoices => Set<SalesInvoice>();
|
||||||
|
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||||
|
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||||
|
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
||||||
|
|
||||||
// --- Reference data (docs/10 Part C.7) ---
|
// --- Reference data (docs/10 Part C.7) ---
|
||||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,106 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("categories", (string)null);
|
b.ToTable("categories", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CustomerId"));
|
||||||
|
|
||||||
|
b.Property<string>("AddressLine1")
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("character varying(250)");
|
||||||
|
|
||||||
|
b.Property<string>("AddressLine2")
|
||||||
|
.HasMaxLength(250)
|
||||||
|
.HasColumnType("character varying(250)");
|
||||||
|
|
||||||
|
b.Property<string>("City")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Country")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CreditDays")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("CreditLimit")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerType")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("B2C");
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultWarehouseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<string>("TaxRegistrationNo")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("DepartmentId")
|
b.Property<int>("DepartmentId")
|
||||||
@@ -3302,6 +3402,406 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("hr_salary_components", (string)null);
|
b.ToTable("hr_salary_components", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SalesInvoiceId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesInvoiceId"));
|
||||||
|
|
||||||
|
b.Property<decimal>("BalanceAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CreatedBy")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("CreatorUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerSnapshotName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerSnapshotTaxNo")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("GrandTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("InvoiceDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("InvoiceNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("InvoiceType")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("B2C");
|
||||||
|
|
||||||
|
b.Property<decimal>("NetPayable")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PaidAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RoundOff")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Draft");
|
||||||
|
|
||||||
|
b.Property<decimal>("Subtotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("SalesInvoiceLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesInvoiceLineId"));
|
||||||
|
|
||||||
|
b.Property<decimal>("BaseCost")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("DiscountMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountPct")
|
||||||
|
.HasPrecision(9, 4)
|
||||||
|
.HasColumnType("numeric(9,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("FreeQty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsFreeIssue")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("LineTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("NetUnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentLineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PriceSource")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Qty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<int>("SalesInvoiceId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxPct")
|
||||||
|
.HasPrecision(9, 4)
|
||||||
|
.HasColumnType("numeric(9,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("UomId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("SalesSlipId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesSlipId"));
|
||||||
|
|
||||||
|
b.Property<decimal>("BalanceAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("CashierUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerSnapshotName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("GrandTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PaidAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SlipDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("SlipNo")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Draft");
|
||||||
|
|
||||||
|
b.Property<decimal>("Subtotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("SalesSlipLineId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SalesSlipLineId"));
|
||||||
|
|
||||||
|
b.Property<decimal>("BaseCost")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("DiscountMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("DiscountPct")
|
||||||
|
.HasPrecision(9, 4)
|
||||||
|
.HasColumnType("numeric(9,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("FreeQty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsFreeIssue")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("ItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("LineTotal")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("NetUnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentLineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PriceSource")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Qty")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<int>("SalesSlipId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxAmount")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TaxPct")
|
||||||
|
.HasPrecision(9, 4)
|
||||||
|
.HasColumnType("numeric(9,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("UomId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("SerialId")
|
b.Property<int>("SerialId")
|
||||||
@@ -4649,6 +5149,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
||||||
@@ -5472,6 +5982,128 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Uom");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
@@ -6014,6 +6646,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Outputs");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ERPCore.Infra.UoW;
|
|||||||
using ERPCore.Repositories;
|
using ERPCore.Repositories;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
using ERPCore.Services;
|
using ERPCore.Services;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.Services.Auth;
|
using ERPCore.Services.Auth;
|
||||||
using ERPCore.Services.Hrm;
|
using ERPCore.Services.Hrm;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -72,6 +73,7 @@ builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
|||||||
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||||
|
|
||||||
// Master-data services (docs/11 §2)
|
// Master-data services (docs/11 §2)
|
||||||
|
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||||
builder.Services.AddScoped<IItemService, ItemService>();
|
builder.Services.AddScoped<IItemService, ItemService>();
|
||||||
builder.Services.AddScoped<IUomService, UomService>();
|
builder.Services.AddScoped<IUomService, UomService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
@@ -97,6 +99,12 @@ builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
|||||||
builder.Services.AddScoped<IStockService, StockService>();
|
builder.Services.AddScoped<IStockService, StockService>();
|
||||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||||
|
|
||||||
|
// Sales (Phase 1)
|
||||||
|
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||||||
|
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||||||
|
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||||||
|
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||||||
|
|
||||||
// Stock transactions + reference data (docs/11 §5–6)
|
// Stock transactions + reference data (docs/11 §5–6)
|
||||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||||
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
||||||
|
|||||||
@@ -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<Customer> _customers;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public CustomerService(IRepository<Customer> customers, IRepository<Warehouse> warehouses, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_customers = customers;
|
||||||
|
_warehouses = warehouses;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<CustomerDto>> 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<CustomerDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<CustomerDto>?> 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<CustomerDto>(Map(customer), customer.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<CustomerDto>> 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<CustomerDto>(Map(customer), customer.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<CustomerDto>> 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<CustomerDto>(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();
|
||||||
|
}
|
||||||
@@ -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<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
|
||||||
|
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -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<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesInvoiceDto>> UpdateAsync(int salesInvoiceId, UpdateSalesInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
|
Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ISalesPricingService
|
||||||
|
{
|
||||||
|
Task<SalesPriceResolution> ResolveAsync(
|
||||||
|
int itemId,
|
||||||
|
int warehouseId,
|
||||||
|
decimal? requestedUnitPrice,
|
||||||
|
bool allowManualOverride,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SalesPriceResolution(
|
||||||
|
decimal UnitPrice,
|
||||||
|
string PriceSource,
|
||||||
|
decimal BaseCost);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ISalesReportService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<SalesDailySummaryRowDto>> DailySummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<SalesItemSummaryRowDto>> ItemSummaryAsync(DateOnly from, DateOnly to, int? itemId, int? warehouseId, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<SalesCustomerSummaryRowDto>> CustomerSummaryAsync(DateOnly from, DateOnly to, int? customerId, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<SalesWarehouseSummaryRowDto>> WarehouseSummaryAsync(DateOnly from, DateOnly to, int? warehouseId, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<SalesDiscountSummaryRowDto>> DiscountSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<SalesFreeIssueSummaryRowDto>> FreeIssueSummaryAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -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<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesSlipDto>> CreateAsync(CreateSalesSlipRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SalesSlipDto>> UpdateAsync(int salesSlipId, UpdateSalesSlipRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -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<SalesInvoice> _invoices;
|
||||||
|
private readonly IRepository<Customer> _customers;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<Uom> _uoms;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly ISalesPricingService _pricing;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly INumberSequenceService _numbers;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public SalesInvoiceService(
|
||||||
|
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
|
||||||
|
IRepository<Uom> uoms, IRepository<Warehouse> 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<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<SalesInvoice> 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<SalesInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesInvoiceDto>?> 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<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesInvoiceDto>> 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<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesInvoiceDto>> 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<SalesInvoiceDto>(Map(invoice), invoice.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesInvoiceDto> 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<SalesInvoiceDto> 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<CreateSalesInvoiceLineRequest> 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<List<SalesInvoiceLine>> BuildLinesAsync(List<CreateSalesInvoiceLineRequest> requests, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lines = new List<SalesInvoiceLine>();
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -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<Item> _items;
|
||||||
|
private readonly IRepository<GrnLine> _grnLines;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
|
|
||||||
|
public SalesPricingService(IRepository<Item> items, IRepository<GrnLine> grnLines, IFifoCostingService fifo)
|
||||||
|
{
|
||||||
|
_items = items;
|
||||||
|
_grnLines = grnLines;
|
||||||
|
_fifo = fifo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesPriceResolution> 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<decimal?> 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<decimal> GetFifoFallbackPriceAsync(int itemId, int warehouseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var valuation = await _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||||
|
return valuation.TotalQty > 0 ? valuation.TotalValue / valuation.TotalQty : 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SalesInvoice> _invoices;
|
||||||
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
|
|
||||||
|
public SalesReportService(IRepository<SalesInvoice> invoices, IRepository<SalesSlip> slips)
|
||||||
|
{
|
||||||
|
_invoices = invoices;
|
||||||
|
_slips = slips;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SalesDailySummaryRowDto>> 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<IReadOnlyList<SalesItemSummaryRowDto>> 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<IReadOnlyList<SalesCustomerSummaryRowDto>> 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<IReadOnlyList<SalesWarehouseSummaryRowDto>> 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<IReadOnlyList<SalesDiscountSummaryRowDto>> 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<IReadOnlyList<SalesFreeIssueSummaryRowDto>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SalesSlip> _slips;
|
||||||
|
private readonly IRepository<Customer> _customers;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<Uom> _uoms;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
|
private readonly ISalesPricingService _pricing;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly INumberSequenceService _numbers;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public SalesSlipService(
|
||||||
|
IRepository<SalesSlip> slips, IRepository<Customer> customers, IRepository<Item> items,
|
||||||
|
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, IRepository<User> 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<PagedResponse<SalesSlipSummaryDto>> ListAsync(PageQuery query, SalesSlipStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<SalesSlip> 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<SalesSlipSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesSlipDto>?> 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<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesSlipDto>> 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<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SalesSlipDto>> 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<SalesSlipDto>(Map(slip), slip.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesSlipDto> 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<SalesSlipDto> 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<CreateSalesSlipLineRequest> 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<List<SalesSlipLine>> BuildLinesAsync(List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lines = new List<SalesSlipLine>();
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user