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);
}