Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c8da2ced | |||
| b7f9f599eb | |||
| f43a8a8486 | |||
| 6ebdbb655a | |||
| 74d3e684d2 | |||
| 2dab7051b3 | |||
| 4f722432cd | |||
| 1f9e12e84b | |||
| 8b8e79e0fe | |||
| ffbd47f6f9 |
@@ -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>
|
||||
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) ---
|
||||
public DbSet<Customer> Customers => Set<Customer>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
||||
public DbSet<Brand> Brands => Set<Brand>();
|
||||
@@ -82,6 +83,12 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
|
||||
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) ---
|
||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||
|
||||
|
||||
-2229
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-2454
File diff suppressed because it is too large
Load Diff
-442
@@ -1,442 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Brand / SubCategory / ItemType masters and the singleton product config,
|
||||
/// and converts CATEGORY from a self-nesting tree into a fixed two-level
|
||||
/// Category → SubCategory hierarchy (docs/10 Part C.1).
|
||||
/// <para>
|
||||
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
|
||||
/// <c>categories.ParentId</c> outright, which would have silently flattened every
|
||||
/// child category into a root and left items pointing at what is now a top-level
|
||||
/// category — losing the parent entirely. The hand-written steps below (marked
|
||||
/// "data migration") move child categories into <c>subcategories</c> and repoint items
|
||||
/// onto the correct (category, subcategory) pair before the column goes away.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// NOTE: the ParentId drop is deliberately deferred to the bottom of this method —
|
||||
// the data migration reads it. Order here is load-bearing.
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ItemType",
|
||||
table: "items",
|
||||
newName: "StockNature");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BrandId",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SubCategoryId",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAt",
|
||||
table: "categories",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Status",
|
||||
table: "categories",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Active");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "UpdatedAt",
|
||||
table: "categories",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<uint>(
|
||||
name: "xmin",
|
||||
table: "categories",
|
||||
type: "xid",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: 0u);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "brands",
|
||||
columns: table => new
|
||||
{
|
||||
BrandId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_brands", x => x.BrandId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "item_types",
|
||||
columns: table => new
|
||||
{
|
||||
ItemTypeId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_item_types", x => x.ItemTypeId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "product_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_product_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_product_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "subcategories",
|
||||
columns: table => new
|
||||
{
|
||||
SubCategoryId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_subcategories", x => x.SubCategoryId);
|
||||
table.ForeignKey(
|
||||
name: "FK_subcategories_categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "categories",
|
||||
principalColumn: "CategoryId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DATA MIGRATION — must run before ParentId is dropped.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Existing categories predate CreatedAt; the added column defaulted them to
|
||||
// 0001-01-01. Stamp them with the migration time instead of a sentinel date.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc';
|
||||
");
|
||||
|
||||
// Carry the old category id alongside each new subcategory so items can be
|
||||
// repointed by join below. Dropped again once the repoint is done.
|
||||
migrationBuilder.Sql(@"
|
||||
ALTER TABLE subcategories ADD COLUMN legacy_category_id integer;
|
||||
");
|
||||
|
||||
// Walk the old tree to its roots. The previous model allowed unlimited nesting,
|
||||
// but the new one is exactly two levels — so a category at any depth below the
|
||||
// root collapses into a subcategory of its ROOT ancestor (a grandchild cannot
|
||||
// become a subcategory of its immediate parent, since that parent is itself
|
||||
// ceasing to be a category).
|
||||
migrationBuilder.Sql(@"
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id
|
||||
FROM categories
|
||||
WHERE ""ParentId"" IS NULL
|
||||
UNION ALL
|
||||
SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id
|
||||
FROM categories c
|
||||
JOIN tree t ON c.""ParentId"" = t.""CategoryId""
|
||||
)
|
||||
INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id)
|
||||
SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId""
|
||||
FROM tree t
|
||||
WHERE t.""ParentId"" IS NOT NULL;
|
||||
");
|
||||
|
||||
// Repoint items: an item that pointed at a child category now carries the root
|
||||
// category plus the subcategory it actually meant.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE items i
|
||||
SET ""SubCategoryId"" = s.""SubCategoryId"",
|
||||
""CategoryId"" = s.""CategoryId""
|
||||
FROM subcategories s
|
||||
WHERE s.legacy_category_id = i.""CategoryId"";
|
||||
");
|
||||
|
||||
// The self-FK must go before the delete, or RESTRICT rejects removing a parent
|
||||
// whose own child row is still present.
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_categories_categories_ParentId",
|
||||
table: "categories");
|
||||
|
||||
// Every non-root category now lives in `subcategories`, and no item references
|
||||
// one any more (repointed above), so the rows can go.
|
||||
migrationBuilder.Sql(@"
|
||||
DELETE FROM categories WHERE ""ParentId"" IS NOT NULL;
|
||||
ALTER TABLE subcategories DROP COLUMN legacy_category_id;
|
||||
");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_categories_ParentId",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ParentId",
|
||||
table: "categories");
|
||||
|
||||
// Seed the singleton config (FR-MD-11) — all features on. Item writes read this
|
||||
// row, so it must exist before the app serves a single request.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"")
|
||||
VALUES (1, TRUE, TRUE, TRUE)
|
||||
ON CONFLICT (""ConfigId"") DO NOTHING;
|
||||
");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_items_BrandId",
|
||||
table: "items",
|
||||
column: "BrandId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_items_SubCategoryId",
|
||||
table: "items",
|
||||
column: "SubCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_categories_Name",
|
||||
table: "categories",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_categories_Status",
|
||||
table: "categories",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_brands_Name",
|
||||
table: "brands",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_brands_Status",
|
||||
table: "brands",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_item_types_Name",
|
||||
table: "item_types",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_item_types_Status",
|
||||
table: "item_types",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_product_config_UpdatedBy",
|
||||
table: "product_config",
|
||||
column: "UpdatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_subcategories_CategoryId_Name",
|
||||
table: "subcategories",
|
||||
columns: new[] { "CategoryId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_subcategories_Status",
|
||||
table: "subcategories",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_items_brands_BrandId",
|
||||
table: "items",
|
||||
column: "BrandId",
|
||||
principalTable: "brands",
|
||||
principalColumn: "BrandId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_items_subcategories_SubCategoryId",
|
||||
table: "items",
|
||||
column: "SubCategoryId",
|
||||
principalTable: "subcategories",
|
||||
principalColumn: "SubCategoryId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses the schema change and puts the subcategory data back where it came from.
|
||||
/// <para>
|
||||
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
|
||||
/// discarded exactly what <see cref="Up"/> preserved. Instead each subcategory is
|
||||
/// restored as a child category and its items are repointed back onto it. This is
|
||||
/// not perfectly lossless: the old tree's depth is gone (a former grandchild comes
|
||||
/// back as a direct child of its root), and Brand data cannot survive a schema that
|
||||
/// has nowhere to put it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_items_brands_BrandId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_items_subcategories_SubCategoryId",
|
||||
table: "items");
|
||||
|
||||
// Restore the parent column + self-FK first so subcategories have somewhere to
|
||||
// land, then move them back before the table is dropped.
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ParentId",
|
||||
table: "categories",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DATA MIGRATION (reverse) — must run before `subcategories` is dropped.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer;
|
||||
");
|
||||
|
||||
// Each subcategory becomes a child category again under the same parent.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id)
|
||||
SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId""
|
||||
FROM subcategories s;
|
||||
");
|
||||
|
||||
// Items that carried a subcategory point back at the restored child category.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE items i
|
||||
SET ""CategoryId"" = c.""CategoryId""
|
||||
FROM categories c
|
||||
WHERE c.legacy_subcategory_id = i.""SubCategoryId"";
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
ALTER TABLE categories DROP COLUMN legacy_subcategory_id;
|
||||
");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "brands");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "item_types");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "product_config");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "subcategories");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_items_BrandId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_items_SubCategoryId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_categories_Name",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_categories_Status",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BrandId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubCategoryId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAt",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Status",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UpdatedAt",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "xmin",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "StockNature",
|
||||
table: "items",
|
||||
newName: "ItemType");
|
||||
|
||||
// ParentId itself was re-added at the top of this method, ahead of the reverse
|
||||
// data migration that populates it.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_categories_ParentId",
|
||||
table: "categories",
|
||||
column: "ParentId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_categories_categories_ParentId",
|
||||
table: "categories",
|
||||
column: "ParentId",
|
||||
principalTable: "categories",
|
||||
principalColumn: "CategoryId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
}
|
||||
}
|
||||
-2454
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ini2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-3001
File diff suppressed because it is too large
Load Diff
-303
@@ -1,303 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRolesNavPermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RoleId",
|
||||
table: "users",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "nav_items",
|
||||
columns: table => new
|
||||
{
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_nav_items", x => x.NavItemId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "roles",
|
||||
columns: table => new
|
||||
{
|
||||
RoleId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_roles", x => x.RoleId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sub_nav_items",
|
||||
columns: table => new
|
||||
{
|
||||
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: false),
|
||||
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId);
|
||||
table.ForeignKey(
|
||||
name: "FK_sub_nav_items_nav_items_NavItemId",
|
||||
column: x => x.NavItemId,
|
||||
principalTable: "nav_items",
|
||||
principalColumn: "NavItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "permissions",
|
||||
columns: table => new
|
||||
{
|
||||
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
NavItemId = table.Column<int>(type: "integer", nullable: true),
|
||||
SubNavItemId = table.Column<int>(type: "integer", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_permissions", x => x.PermissionId);
|
||||
table.ForeignKey(
|
||||
name: "FK_permissions_nav_items_NavItemId",
|
||||
column: x => x.NavItemId,
|
||||
principalTable: "nav_items",
|
||||
principalColumn: "NavItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_permissions_sub_nav_items_SubNavItemId",
|
||||
column: x => x.SubNavItemId,
|
||||
principalTable: "sub_nav_items",
|
||||
principalColumn: "SubNavItemId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "role_permissions",
|
||||
columns: table => new
|
||||
{
|
||||
RoleId = table.Column<int>(type: "integer", nullable: false),
|
||||
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId });
|
||||
table.ForeignKey(
|
||||
name: "FK_role_permissions_permissions_PermissionId",
|
||||
column: x => x.PermissionId,
|
||||
principalTable: "permissions",
|
||||
principalColumn: "PermissionId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_role_permissions_roles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "roles",
|
||||
principalColumn: "RoleId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "dashboard", "/dashboard", null, "Dashboard", 1 },
|
||||
{ 2, "products", "/dashboard/products", null, "Products", 2 },
|
||||
{ 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 },
|
||||
{ 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 },
|
||||
{ 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 },
|
||||
{ 6, "stock", "/dashboard/stock", null, "Stock", 6 },
|
||||
{ 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 },
|
||||
{ 8, "orders", "/dashboard/orders", null, "Orders", 8 },
|
||||
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
|
||||
{ 10, "help", "/dashboard/help", null, "Help", 10 }
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "users",
|
||||
keyColumn: "UserId",
|
||||
keyValue: 1,
|
||||
column: "RoleId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "NAV:dashboard", 1, null },
|
||||
{ 2, "NAV:products", 2, null },
|
||||
{ 3, "NAV:vendors", 3, null },
|
||||
{ 4, "NAV:procurement", 4, null },
|
||||
{ 5, "NAV:receiving", 5, null },
|
||||
{ 6, "NAV:stock", 6, null },
|
||||
{ 7, "NAV:warehouses", 7, null },
|
||||
{ 8, "NAV:orders", 8, null },
|
||||
{ 9, "NAV:settings", 9, null },
|
||||
{ 10, "NAV:help", 10, null }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "products.item", "/dashboard/products", null, "Item", 2, 1 },
|
||||
{ 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 },
|
||||
{ 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 },
|
||||
{ 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 },
|
||||
{ 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 },
|
||||
{ 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 },
|
||||
{ 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 },
|
||||
{ 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 11, "NAV:products.item", null, 1 },
|
||||
{ 12, "NAV:products.category", null, 2 },
|
||||
{ 13, "NAV:products.brand", null, 3 },
|
||||
{ 14, "NAV:products.item-type", null, 4 },
|
||||
{ 15, "NAV:products.uom", null, 5 },
|
||||
{ 16, "NAV:products.configuration", null, 6 },
|
||||
{ 17, "NAV:settings.roles", null, 7 },
|
||||
{ 18, "NAV:settings.users", null, 8 }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_users_RoleId",
|
||||
table: "users",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_nav_items_Code",
|
||||
table: "nav_items",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_permissions_Code",
|
||||
table: "permissions",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_permissions_NavItemId",
|
||||
table: "permissions",
|
||||
column: "NavItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_permissions_SubNavItemId",
|
||||
table: "permissions",
|
||||
column: "SubNavItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_role_permissions_PermissionId",
|
||||
table: "role_permissions",
|
||||
column: "PermissionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_roles_auth_role_id",
|
||||
table: "roles",
|
||||
column: "auth_role_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_roles_Code",
|
||||
table: "roles",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sub_nav_items_Code",
|
||||
table: "sub_nav_items",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sub_nav_items_NavItemId",
|
||||
table: "sub_nav_items",
|
||||
column: "NavItemId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_users_roles_RoleId",
|
||||
table: "users",
|
||||
column: "RoleId",
|
||||
principalTable: "roles",
|
||||
principalColumn: "RoleId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_users_roles_RoleId",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "role_permissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "permissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "roles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sub_nav_items");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "nav_items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_users_RoleId",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RoleId",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
-3128
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLedgersNavSeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[] { 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 19, "NAV:ledgers", 11, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 9, "ledgers.trial-balance", "/dashboard/ledgers/trial-balance", null, "Trial Balance", 11, 1 },
|
||||
{ 10, "ledgers.balance-sheet", "/dashboard/ledgers/balance-sheet", null, "Balance Sheet", 11, 2 },
|
||||
{ 11, "ledgers.general-ledger", "/dashboard/ledgers/general-ledger", null, "General Ledger", 11, 3 },
|
||||
{ 12, "ledgers.profit-and-loss", "/dashboard/ledgers/profit-and-loss", null, "Profit & Loss", 11, 4 },
|
||||
{ 13, "ledgers.cash-flow", "/dashboard/ledgers/cash-flow", null, "Cash Flow", 11, 5 },
|
||||
{ 14, "ledgers.budget-vs-actual", "/dashboard/ledgers/budget-vs-actual", null, "Budget vs Actual", 11, 6 },
|
||||
{ 15, "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", null, "Cash / Bank Accounts", 11, 7 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 20, "NAV:ledgers.trial-balance", null, 9 },
|
||||
{ 21, "NAV:ledgers.balance-sheet", null, 10 },
|
||||
{ 22, "NAV:ledgers.general-ledger", null, 11 },
|
||||
{ 23, "NAV:ledgers.profit-and-loss", null, 12 },
|
||||
{ 24, "NAV:ledgers.cash-flow", null, 13 },
|
||||
{ 25, "NAV:ledgers.budget-vs-actual", null, 14 },
|
||||
{ 26, "NAV:ledgers.bank-accounts", null, 15 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 19);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 20);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 21);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 22);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 23);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 24);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 25);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 26);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 9);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 10);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 11);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 12);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 13);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 14);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 11);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-3144
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaxReportNavSeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
column: "SortOrder",
|
||||
value: 8);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[] { 16, "ledgers.tax-report", "/dashboard/ledgers/tax-report", null, "Tax Report", 11, 7 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 27, "NAV:ledgers.tax-report", null, 16 });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 27);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 16);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
column: "SortOrder",
|
||||
value: 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend/ERPCore/Infra/Persistence/Migrations/20260730105356_FixProcurementNavIdCollision.Designer.cs
Generated
-5152
File diff suppressed because it is too large
Load Diff
-82
@@ -1,82 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixProcurementNavIdCollision : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 17, "procurement.requisitions", "/dashboard/procurement/requisitions", null, "Requisitions", 4, 1 },
|
||||
{ 18, "procurement.rfqs", "/dashboard/procurement/rfqs", null, "RFQs", 4, 2 },
|
||||
{ 19, "procurement.purchase-orders", "/dashboard/procurement/purchase-orders", null, "Purchase Orders", 4, 3 },
|
||||
{ 20, "procurement.purchase-returns", "/dashboard/procurement/purchase-returns", null, "Purchase Returns", 4, 4 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 28, "NAV:procurement.requisitions", null, 17 },
|
||||
{ 29, "NAV:procurement.rfqs", null, 18 },
|
||||
{ 30, "NAV:procurement.purchase-orders", null, 19 },
|
||||
{ 31, "NAV:procurement.purchase-returns", null, 20 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 28);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 29);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 30);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 31);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 17);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 18);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 19);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-5199
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountsNavSeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||
values: new object[] { 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 26,
|
||||
column: "Code",
|
||||
value: "NAV:accounts.bank-accounts");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
|
||||
values: new object[] { "accounts.bank-accounts", "/dashboard/accounts/bank-accounts", 12, 1 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 32, "NAV:accounts", 12, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 21, "accounts.cheque-books", "/dashboard/accounts/cheque-books", null, "Cheque Books", 12, 2 },
|
||||
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 33, "NAV:accounts.cheque-books", null, 21 },
|
||||
{ 34, "NAV:accounts.received-cheques", null, 22 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 32);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 33);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 34);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 21);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 22);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 12);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 26,
|
||||
column: "Code",
|
||||
value: "NAV:ledgers.bank-accounts");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 15,
|
||||
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
|
||||
values: new object[] { "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", 11, 8 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class production : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
-6054
File diff suppressed because it is too large
Load Diff
-1871
File diff suppressed because it is too large
Load Diff
+644
-2
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(ErpDbContext))]
|
||||
[Migration("20260731123720_production")]
|
||||
partial class production
|
||||
[Migration("20260801025920_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -410,6 +410,106 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("DepartmentId")
|
||||
@@ -3305,6 +3405,406 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("SerialId")
|
||||
@@ -4652,6 +5152,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
||||
@@ -5475,6 +5985,128 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Uom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorUserId");
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("SalesInvoiceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||
.WithMany()
|
||||
.HasForeignKey("UomId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("SalesInvoice");
|
||||
|
||||
b.Navigation("Uom");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("CashierUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CashierUser");
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesSlip", "SalesSlip")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("SalesSlipId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||
.WithMany()
|
||||
.HasForeignKey("UomId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("SalesSlip");
|
||||
|
||||
b.Navigation("Uom");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
@@ -6017,6 +6649,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Outputs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -407,6 +407,106 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("DepartmentId")
|
||||
@@ -3302,6 +3402,406 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("SerialId")
|
||||
@@ -4649,6 +5149,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("DefaultWarehouseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("DefaultWarehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
||||
@@ -5472,6 +5982,128 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Uom");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorUserId");
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("SalesInvoiceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||
.WithMany()
|
||||
.HasForeignKey("UomId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("SalesInvoice");
|
||||
|
||||
b.Navigation("Uom");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("CashierUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CashierUser");
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesSlip", "SalesSlip")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("SalesSlipId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Uom", "Uom")
|
||||
.WithMany()
|
||||
.HasForeignKey("UomId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("SalesSlip");
|
||||
|
||||
b.Navigation("Uom");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
@@ -6014,6 +6646,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Outputs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
|
||||
@@ -8,6 +8,7 @@ using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Auth;
|
||||
using ERPCore.Services.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -72,6 +73,7 @@ builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
|
||||
// Master-data services (docs/11 §2)
|
||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<IItemService, ItemService>();
|
||||
builder.Services.AddScoped<IUomService, UomService>();
|
||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||
@@ -97,6 +99,12 @@ builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
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)
|
||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||
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());
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=dbuser"
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreDev;Username=postgres;Password=root"
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:5011"
|
||||
|
||||
@@ -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