implemnet customer master used for both B2B and B2C.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Customers;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||
[Route("api/v1/customers")]
|
||||
public sealed class CustomersController : ApiControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customers;
|
||||
|
||||
public CustomersController(ICustomerService customers) => _customers = customers;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CustomerDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CustomerDto>> GetById(int customerId, CancellationToken ct)
|
||||
{
|
||||
var result = await _customers.GetAsync(customerId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _customers.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<CustomerDto>> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _customers.UpdateAsync(customerId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPatch("{customerId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _customers.SetStatusAsync(customerId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,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