implemnet customer master used for both B2B and B2C.

This commit is contained in:
2026-07-27 14:17:00 +05:30
committed by ImanThiyanga
parent 76484c7268
commit ffbd47f6f9
10 changed files with 800 additions and 0 deletions
@@ -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,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,8 @@
namespace ERPCore.Domain.Enums;
public enum CustomerType
{
B2B = 1,
B2C = 2,
WalkIn = 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,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);
}
}
@@ -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>();
+2
View File
@@ -7,6 +7,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Auth;
using ERPCore.Services.Hrm;
using ERPCore.Services.Interfaces;
@@ -62,6 +63,7 @@ builder.Services.AddScoped<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>();
+168
View File
@@ -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);
}
+383
View File
@@ -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.