diff --git a/Backend/ERPCore/Controllers/CustomersController.cs b/Backend/ERPCore/Controllers/CustomersController.cs new file mode 100644 index 0000000..ff7cda4 --- /dev/null +++ b/Backend/ERPCore/Controllers/CustomersController.cs @@ -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; + +/// Customer master endpoints for Phase 1 sales. +[Route("api/v1/customers")] +public sealed class CustomersController : ApiControllerBase +{ + private readonly ICustomerService _customers; + + public CustomersController(ICustomerService customers) => _customers = customers; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> 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> 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 SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct) + { + await _customers.SetStatusAsync(customerId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Domain/Entities/Customer.cs b/Backend/ERPCore/Domain/Entities/Customer.cs new file mode 100644 index 0000000..916a1b9 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Customer.cs @@ -0,0 +1,38 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Customer master for both B2B and B2C sales. +/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse. +/// +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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/CustomerType.cs b/Backend/ERPCore/Domain/Enums/CustomerType.cs new file mode 100644 index 0000000..5928ba5 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/CustomerType.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum CustomerType +{ + B2B = 1, + B2C = 2, + WalkIn = 3 +} diff --git a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs new file mode 100644 index 0000000..3b7175d --- /dev/null +++ b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Customers; + +/// Customer resource used by sales documents. +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; } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs new file mode 100644 index 0000000..a52b327 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().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().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index ca895cd..1f4d2ce 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -22,6 +22,7 @@ public class ErpDbContext : DbContext } // --- Master Data (docs/10 Part C.1) --- + public DbSet Customers => Set(); public DbSet Categories => Set(); public DbSet SubCategories => Set(); public DbSet Brands => Set(); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 1a40914..5497874 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -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(); builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); // Master-data services (docs/11 ยง2) +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/CustomerService.cs b/Backend/ERPCore/Services/CustomerService.cs new file mode 100644 index 0000000..0f7c64e --- /dev/null +++ b/Backend/ERPCore/Services/CustomerService.cs @@ -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 _customers; + private readonly IRepository _warehouses; + private readonly IUnitOfWork _uow; + + public CustomerService(IRepository customers, IRepository warehouses, IUnitOfWork uow) + { + _customers = customers; + _warehouses = warehouses; + _uow = uow; + } + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> 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(Map(customer), customer.RowVersion); + } + + public async Task> 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(Map(customer), customer.RowVersion); + } + + public async Task> 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(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(); +} diff --git a/Backend/ERPCore/Services/Interfaces/ICustomerService.cs b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs new file mode 100644 index 0000000..47e837c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs @@ -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> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default); + Task?> GetAsync(int customerId, CancellationToken ct = default); + Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default); + Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default); +} diff --git a/docs/SALES_MODULE_PLAN.md b/docs/SALES_MODULE_PLAN.md new file mode 100644 index 0000000..efb4bea --- /dev/null +++ b/docs/SALES_MODULE_PLAN.md @@ -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.