Add bundle sales backend implementation
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
[Route("api/v1/bundle-sales")]
|
||||||
|
public sealed class BundleSalesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBundleSaleService _bundles;
|
||||||
|
|
||||||
|
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
|
||||||
|
|
||||||
|
[HttpGet("templates")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
|
||||||
|
[FromQuery] PageQuery query,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
|
||||||
|
|
||||||
|
[HttpGet("templates/{bundleSaleTemplateId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query,
|
||||||
|
[FromQuery] int? customerId,
|
||||||
|
[FromQuery] int? warehouseId,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{bundleSaleId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _bundles.GetAsync(bundleSaleId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{bundleSaleId:int}/posting-check")]
|
||||||
|
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
|
||||||
|
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
|
||||||
|
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _bundles.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{bundleSaleId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{bundleSaleId:int}/post")]
|
||||||
|
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
|
||||||
|
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{bundleSaleId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
|
||||||
|
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
|
||||||
|
}
|
||||||
@@ -19,4 +19,5 @@ public static class DocumentTypes
|
|||||||
public const string Production = "PRD";
|
public const string Production = "PRD";
|
||||||
public const string SalesInvoice = "SI";
|
public const string SalesInvoice = "SI";
|
||||||
public const string SalesSlip = "SSL";
|
public const string SalesSlip = "SSL";
|
||||||
|
public const string BundleSale = "BND";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
public class BundleSale
|
||||||
|
{
|
||||||
|
public int BundleSaleId { get; set; }
|
||||||
|
public string BundleNo { get; set; } = string.Empty;
|
||||||
|
public DateTime BundleDate { get; set; }
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
public Customer? Customer { get; set; }
|
||||||
|
public string CustomerSnapshotName { get; set; } = string.Empty;
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
public int CashierUserId { get; set; }
|
||||||
|
public User? CashierUser { get; set; }
|
||||||
|
public int BundleSaleTemplateId { get; set; }
|
||||||
|
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
|
||||||
|
public string BundleName { get; set; } = string.Empty;
|
||||||
|
public string BundleCode { get; set; } = string.Empty;
|
||||||
|
public BundleSaleStatus Status { get; set; } = BundleSaleStatus.Draft;
|
||||||
|
public decimal ComponentSubtotal { get; set; }
|
||||||
|
public decimal BundlePrice { get; set; }
|
||||||
|
public decimal MarginAmount { get; set; }
|
||||||
|
public decimal DiscountTotal { get; set; }
|
||||||
|
public decimal TaxTotal { get; set; }
|
||||||
|
public decimal GrandTotal { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public int ConcurrencyStamp { get; set; }
|
||||||
|
|
||||||
|
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
public class BundleSaleLine
|
||||||
|
{
|
||||||
|
public int BundleSaleLineId { get; set; }
|
||||||
|
public int BundleSaleId { get; set; }
|
||||||
|
public BundleSale? BundleSale { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
public int UomId { get; set; }
|
||||||
|
public Uom? Uom { get; set; }
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
public bool IncludeInBundle { get; set; } = true;
|
||||||
|
public bool IsComponent { get; set; } = true;
|
||||||
|
public int? ParentLineId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
public class BundleSaleTemplate
|
||||||
|
{
|
||||||
|
public int BundleSaleTemplateId { get; set; }
|
||||||
|
public string TemplateCode { get; set; } = string.Empty;
|
||||||
|
public string TemplateName { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public int ConcurrencyStamp { get; set; }
|
||||||
|
|
||||||
|
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
public class BundleSaleTemplateLine
|
||||||
|
{
|
||||||
|
public int BundleSaleTemplateLineId { get; set; }
|
||||||
|
public int BundleSaleTemplateId { get; set; }
|
||||||
|
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
public int UomId { get; set; }
|
||||||
|
public Uom? Uom { get; set; }
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public bool IncludeInBundle { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
public enum BundleSaleStatus
|
||||||
|
{
|
||||||
|
Draft = 0,
|
||||||
|
Posted = 1,
|
||||||
|
Cancelled = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
public sealed record BundleSaleLineDto(
|
||||||
|
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId,
|
||||||
|
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
|
||||||
|
|
||||||
|
public sealed record BundleSaleDto(
|
||||||
|
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
|
||||||
|
int WarehouseId, int CashierUserId, int BundleSaleTemplateId, string BundleName, string BundleCode,
|
||||||
|
BundleSaleStatus Status, decimal ComponentSubtotal, decimal BundlePrice, decimal MarginAmount,
|
||||||
|
decimal DiscountTotal, decimal TaxTotal, decimal GrandTotal, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
|
IReadOnlyList<BundleSaleLineDto> Lines);
|
||||||
|
|
||||||
|
public sealed record BundleSaleSummaryDto(
|
||||||
|
int BundleSaleId, string BundleNo, DateTime BundleDate, int CustomerId, string CustomerSnapshotName,
|
||||||
|
int WarehouseId, string BundleName, string BundleCode, BundleSaleStatus Status,
|
||||||
|
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public sealed record BundleSaleTemplateLineDto(
|
||||||
|
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
|
||||||
|
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
|
||||||
|
|
||||||
|
public sealed record BundleSaleTemplateDto(
|
||||||
|
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
||||||
|
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
|
||||||
|
|
||||||
|
public sealed record BundleSaleTemplateSummaryDto(
|
||||||
|
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
|
||||||
|
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
public sealed record BundleSalePostingIssueDto(
|
||||||
|
int BundleSaleLineId, int ItemId, string ItemSku, string ItemName, int WarehouseId,
|
||||||
|
decimal RequestedQty, decimal AvailableQty, decimal ShortQty);
|
||||||
|
|
||||||
|
public sealed record BundleSalePostingCheckDto(
|
||||||
|
int BundleSaleId, string BundleNo, BundleSaleStatus Status, bool CanPost,
|
||||||
|
IReadOnlyList<BundleSalePostingIssueDto> Issues);
|
||||||
|
|
||||||
|
public sealed class CreateBundleSaleTemplateLineRequest
|
||||||
|
{
|
||||||
|
[Required] public int ItemId { get; set; }
|
||||||
|
[Required] public int UomId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||||
|
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||||
|
public bool IncludeInBundle { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateBundleSaleTemplateRequest
|
||||||
|
{
|
||||||
|
[Required] public string TemplateCode { get; set; } = string.Empty;
|
||||||
|
[Required] public string TemplateName { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateBundleSaleRequest
|
||||||
|
{
|
||||||
|
[Required] public int CustomerId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
[Required] public int CashierUserId { get; set; }
|
||||||
|
[Required] public int BundleSaleTemplateId { get; set; }
|
||||||
|
[Required] public string BundleName { get; set; } = string.Empty;
|
||||||
|
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
||||||
|
public bool AllowPriceOverride { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateBundleSaleRequest
|
||||||
|
{
|
||||||
|
[Required] public int CustomerId { get; set; }
|
||||||
|
[Required] public int WarehouseId { get; set; }
|
||||||
|
[Required] public int CashierUserId { get; set; }
|
||||||
|
[Required] public int BundleSaleTemplateId { get; set; }
|
||||||
|
[Required] public string BundleName { get; set; } = string.Empty;
|
||||||
|
[Range(0, double.MaxValue)] public decimal BundlePrice { get; set; }
|
||||||
|
public bool AllowPriceOverride { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSale>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BundleSale> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("bundle_sales");
|
||||||
|
builder.HasKey(x => x.BundleSaleId);
|
||||||
|
builder.Property(x => x.BundleNo).IsRequired().HasMaxLength(50);
|
||||||
|
builder.HasIndex(x => x.BundleNo).IsUnique();
|
||||||
|
builder.Property(x => x.BundleDate).IsRequired();
|
||||||
|
builder.Property(x => x.CustomerSnapshotName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(x => x.BundleName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(x => x.BundleCode).IsRequired().HasMaxLength(50);
|
||||||
|
builder.Property(x => x.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(BundleSaleStatus.Draft);
|
||||||
|
builder.Property(x => x.ComponentSubtotal).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.BundlePrice).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.MarginAmount).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.DiscountTotal).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
|
builder.Property(x => x.ConcurrencyStamp)
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.IsConcurrencyToken();
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.BundleSaleTemplate).WithMany().HasForeignKey(x => x.BundleSaleTemplateId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasMany(x => x.Lines)
|
||||||
|
.WithOne(x => x.BundleSale)
|
||||||
|
.HasForeignKey(x => x.BundleSaleId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class BundleSaleLineConfiguration : IEntityTypeConfiguration<BundleSaleLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BundleSaleLine> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("bundle_sale_lines");
|
||||||
|
builder.HasKey(x => x.BundleSaleLineId);
|
||||||
|
builder.Property(x => x.Description).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.IncludeInBundle).HasDefaultValue(true);
|
||||||
|
builder.Property(x => x.IsComponent).HasDefaultValue(true);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<BundleSaleTemplate>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BundleSaleTemplate> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("bundle_sale_templates");
|
||||||
|
builder.HasKey(x => x.BundleSaleTemplateId);
|
||||||
|
|
||||||
|
builder.Property(x => x.TemplateCode).IsRequired().HasMaxLength(50);
|
||||||
|
builder.HasIndex(x => x.TemplateCode).IsUnique();
|
||||||
|
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(x => x.Description).HasMaxLength(1000);
|
||||||
|
builder.Property(x => x.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
|
builder.Property(x => x.ConcurrencyStamp)
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.IsConcurrencyToken();
|
||||||
|
|
||||||
|
builder.HasMany(x => x.Lines)
|
||||||
|
.WithOne(x => x.BundleSaleTemplate)
|
||||||
|
.HasForeignKey(x => x.BundleSaleTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class BundleSaleTemplateLineConfiguration : IEntityTypeConfiguration<BundleSaleTemplateLine>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BundleSaleTemplateLine> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("bundle_sale_template_lines");
|
||||||
|
builder.HasKey(x => x.BundleSaleTemplateLineId);
|
||||||
|
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||||
|
builder.Property(x => x.SortOrder).HasDefaultValue(0);
|
||||||
|
|
||||||
|
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Uom).WithMany().HasForeignKey(x => x.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
|||||||
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
||||||
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
|
||||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 }
|
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
||||||
|
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
|||||||
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
||||||
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||||
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
|
||||||
|
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
|
||||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||||
// these procurement rows were never actually migrated into the database before now.
|
// these procurement rows were never actually migrated into the database before now.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public static class DataSeeder
|
|||||||
{
|
{
|
||||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||||
dirty |= await SeedItemTypesAsync(db, ct);
|
dirty |= await SeedItemTypesAsync(db, ct);
|
||||||
dirty |= await SeedCompanyProfileAsync(db, ct);
|
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||||
dirty |= await SeedProductConfigAsync(db, ct);
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||||
dirty |= await SeedSalesStockAsync(db, ct);
|
dirty |= await SeedSalesStockAsync(db, ct);
|
||||||
@@ -109,31 +109,31 @@ public static class DataSeeder
|
|||||||
/// Seeds a printable company profile with reasonable defaults for invoice headers.
|
/// Seeds a printable company profile with reasonable defaults for invoice headers.
|
||||||
/// These values are intentionally editable later through the API.
|
/// These values are intentionally editable later through the API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
|
//private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
//{
|
||||||
if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
|
// if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
|
||||||
|
|
||||||
db.CompanyProfiles.Add(new CompanyProfile
|
// db.CompanyProfiles.Add(new CompanyProfile
|
||||||
{
|
// {
|
||||||
CompanyProfileId = CompanyProfile.SingletonId,
|
// CompanyProfileId = CompanyProfile.SingletonId,
|
||||||
LegalName = "ERP Core Trading (Pvt) Ltd",
|
// LegalName = "ERP Core Trading (Pvt) Ltd",
|
||||||
TradeName = "ERP Core Trading",
|
// TradeName = "ERP Core Trading",
|
||||||
TaxRegistrationNo = "TAX-DEFAULT-001",
|
// TaxRegistrationNo = "TAX-DEFAULT-001",
|
||||||
VatRegistrationNo = "VAT-DEFAULT-001",
|
// VatRegistrationNo = "VAT-DEFAULT-001",
|
||||||
AddressLine1 = "1 Demo Street",
|
// AddressLine1 = "1 Demo Street",
|
||||||
City = "Colombo",
|
// City = "Colombo",
|
||||||
Country = "Sri Lanka",
|
// Country = "Sri Lanka",
|
||||||
Phone = "+94 11 000 0000",
|
// Phone = "+94 11 000 0000",
|
||||||
Email = "accounts@example.com",
|
// Email = "accounts@example.com",
|
||||||
BankName = "Demo Bank",
|
// BankName = "Demo Bank",
|
||||||
BankBranch = "Colombo Main",
|
// BankBranch = "Colombo Main",
|
||||||
AccountName = "ERP Core Trading (Pvt) Ltd",
|
// AccountName = "ERP Core Trading (Pvt) Ltd",
|
||||||
AccountNumber = "000123456789",
|
// AccountNumber = "000123456789",
|
||||||
SwiftCode = "DEMO1234",
|
// SwiftCode = "DEMO1234",
|
||||||
FooterNote = "Thank you for your business."
|
// FooterNote = "Thank you for your business."
|
||||||
});
|
// });
|
||||||
return true;
|
// return true;
|
||||||
}
|
//}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds the minimum catalog data required for the sales demo rows to exist.
|
/// Seeds the minimum catalog data required for the sales demo rows to exist.
|
||||||
@@ -318,6 +318,15 @@ public static class DataSeeder
|
|||||||
dirty |= await SeedSalesSequencesAsync(db, ct);
|
dirty |= await SeedSalesSequencesAsync(db, ct);
|
||||||
dirty |= await SeedSampleSalesDocsAsync(db, ct);
|
dirty |= await SeedSampleSalesDocsAsync(db, ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dirty |= await SeedBundleSalesAsync(db, ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Bundle demo data is best-effort only; never block startup because of seed drift.
|
||||||
|
}
|
||||||
|
|
||||||
return dirty;
|
return dirty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +378,7 @@ public static class DataSeeder
|
|||||||
{
|
{
|
||||||
var year = DateTime.UtcNow.Year;
|
var year = DateTime.UtcNow.Year;
|
||||||
var existing = await db.NumberSequences
|
var existing = await db.NumberSequences
|
||||||
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip))
|
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip || s.DocType == DocumentTypes.BundleSale))
|
||||||
.Select(s => s.DocType)
|
.Select(s => s.DocType)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -377,7 +386,8 @@ public static class DataSeeder
|
|||||||
var seeds = new[]
|
var seeds = new[]
|
||||||
{
|
{
|
||||||
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
|
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
|
||||||
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 }
|
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 },
|
||||||
|
new NumberSequence { DocType = DocumentTypes.BundleSale, Year = year, LastNumber = 0 }
|
||||||
};
|
};
|
||||||
|
|
||||||
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
|
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
|
||||||
@@ -387,6 +397,209 @@ public static class DataSeeder
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedBundleSalesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.BundleSaleTemplates.AnyAsync(ct) || await db.BundleSales.AnyAsync(ct))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var customer = await db.Customers.AsNoTracking()
|
||||||
|
.OrderBy(c => c.CustomerId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var warehouse = await db.Warehouses.AsNoTracking()
|
||||||
|
.OrderBy(w => w.WarehouseId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
|
||||||
|
.OrderByDescending(w => w.WarehouseId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var items = await db.Items.AsNoTracking()
|
||||||
|
.OrderBy(i => i.ItemId)
|
||||||
|
.Take(2)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var uom = await db.Uoms.AsNoTracking()
|
||||||
|
.OrderBy(u => u.UomId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var user = await db.Users.AsNoTracking()
|
||||||
|
.OrderBy(u => u.UserId)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var template = new BundleSaleTemplate
|
||||||
|
{
|
||||||
|
TemplateCode = "BND-DEMO-001",
|
||||||
|
TemplateName = "Demo Bundle Pack",
|
||||||
|
Description = "Seeded fixed bundle template for integration testing",
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = now,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new BundleSaleTemplateLine
|
||||||
|
{
|
||||||
|
ItemId = items[0].ItemId,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
Qty = 1m,
|
||||||
|
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
IncludeInBundle = true,
|
||||||
|
SortOrder = 1
|
||||||
|
},
|
||||||
|
new BundleSaleTemplateLine
|
||||||
|
{
|
||||||
|
ItemId = items[1].ItemId,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
Qty = 1m,
|
||||||
|
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
IncludeInBundle = true,
|
||||||
|
SortOrder = 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
db.BundleSaleTemplates.Add(template);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var bundleSales = new[]
|
||||||
|
{
|
||||||
|
new BundleSale
|
||||||
|
{
|
||||||
|
BundleNo = $"BND-{now:yyyy}-00001",
|
||||||
|
BundleDate = now.Date.AddDays(-2),
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
CashierUserId = user.UserId,
|
||||||
|
BundleSaleTemplateId = template.BundleSaleTemplateId,
|
||||||
|
BundleName = "Demo Bundle Draft",
|
||||||
|
BundleCode = "BND-DEMO-001",
|
||||||
|
Status = BundleSaleStatus.Draft,
|
||||||
|
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
BundlePrice = 0m,
|
||||||
|
MarginAmount = -(items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()),
|
||||||
|
DiscountTotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = 0m,
|
||||||
|
CreatedAt = now.AddDays(-2),
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new BundleSaleLine
|
||||||
|
{
|
||||||
|
ItemId = items[0].ItemId,
|
||||||
|
Description = items[0].Name,
|
||||||
|
Qty = 1m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
IncludeInBundle = true,
|
||||||
|
IsComponent = true
|
||||||
|
},
|
||||||
|
new BundleSaleLine
|
||||||
|
{
|
||||||
|
ItemId = items[1].ItemId,
|
||||||
|
Description = items[1].Name,
|
||||||
|
Qty = 1m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
IncludeInBundle = true,
|
||||||
|
IsComponent = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new BundleSale
|
||||||
|
{
|
||||||
|
BundleNo = $"BND-{now:yyyy}-00002",
|
||||||
|
BundleDate = now.Date.AddDays(-1),
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
CashierUserId = user.UserId,
|
||||||
|
BundleSaleTemplateId = template.BundleSaleTemplateId,
|
||||||
|
BundleName = "Demo Bundle Posted",
|
||||||
|
BundleCode = "BND-DEMO-001",
|
||||||
|
Status = BundleSaleStatus.Posted,
|
||||||
|
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
BundlePrice = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
|
||||||
|
MarginAmount = -10m,
|
||||||
|
DiscountTotal = 10m,
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = (items[0].SalePrice.GetValueOrDefault() + items[1].SalePrice.GetValueOrDefault()) - 10m,
|
||||||
|
CreatedAt = now.AddDays(-1),
|
||||||
|
UpdatedAt = now.AddHours(-2),
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new BundleSaleLine
|
||||||
|
{
|
||||||
|
ItemId = items[0].ItemId,
|
||||||
|
Description = items[0].Name,
|
||||||
|
Qty = 1m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
IncludeInBundle = true,
|
||||||
|
IsComponent = true
|
||||||
|
},
|
||||||
|
new BundleSaleLine
|
||||||
|
{
|
||||||
|
ItemId = items[1].ItemId,
|
||||||
|
Description = items[1].Name,
|
||||||
|
Qty = 1m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = warehouse.WarehouseId,
|
||||||
|
UnitPrice = items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
LineTotal = items[1].SalePrice.GetValueOrDefault(),
|
||||||
|
IncludeInBundle = true,
|
||||||
|
IsComponent = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new BundleSale
|
||||||
|
{
|
||||||
|
BundleNo = $"BND-{now:yyyy}-00003",
|
||||||
|
BundleDate = now.Date,
|
||||||
|
CustomerId = customer.CustomerId,
|
||||||
|
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
|
||||||
|
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||||
|
CashierUserId = user.UserId,
|
||||||
|
BundleSaleTemplateId = template.BundleSaleTemplateId,
|
||||||
|
BundleName = "Demo Bundle Cancelled",
|
||||||
|
BundleCode = "BND-DEMO-001",
|
||||||
|
Status = BundleSaleStatus.Cancelled,
|
||||||
|
ComponentSubtotal = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
BundlePrice = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
MarginAmount = 0m,
|
||||||
|
DiscountTotal = 0m,
|
||||||
|
TaxTotal = 0m,
|
||||||
|
GrandTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now,
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new BundleSaleLine
|
||||||
|
{
|
||||||
|
ItemId = items[0].ItemId,
|
||||||
|
Description = items[0].Name,
|
||||||
|
Qty = 1m,
|
||||||
|
UomId = uom.UomId,
|
||||||
|
WarehouseId = secondaryWarehouse.WarehouseId,
|
||||||
|
UnitPrice = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
LineTotal = items[0].SalePrice.GetValueOrDefault(),
|
||||||
|
IncludeInBundle = true,
|
||||||
|
IsComponent = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
db.BundleSales.AddRange(bundleSales);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
|
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
|
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
public DbSet<SalesInvoiceLine> SalesInvoiceLines => Set<SalesInvoiceLine>();
|
||||||
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
public DbSet<SalesSlip> SalesSlips => Set<SalesSlip>();
|
||||||
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
public DbSet<SalesSlipLine> SalesSlipLines => Set<SalesSlipLine>();
|
||||||
|
public DbSet<BundleSaleTemplate> BundleSaleTemplates => Set<BundleSaleTemplate>();
|
||||||
|
public DbSet<BundleSaleTemplateLine> BundleSaleTemplateLines => Set<BundleSaleTemplateLine>();
|
||||||
|
public DbSet<BundleSale> BundleSales => Set<BundleSale>();
|
||||||
|
public DbSet<BundleSaleLine> BundleSaleLines => Set<BundleSaleLine>();
|
||||||
|
|
||||||
// --- Reference data (docs/10 Part C.7) ---
|
// --- Reference data (docs/10 Part C.7) ---
|
||||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
using ERPCore.Domain;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.Services.Stock;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
public sealed class BundleSaleService : IBundleSaleService
|
||||||
|
{
|
||||||
|
private readonly IRepository<BundleSaleTemplate> _templates;
|
||||||
|
private readonly IRepository<BundleSale> _bundles;
|
||||||
|
private readonly IRepository<Customer> _customers;
|
||||||
|
private readonly IRepository<Item> _items;
|
||||||
|
private readonly IRepository<Uom> _uoms;
|
||||||
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IRepository<User> _users;
|
||||||
|
private readonly ISalesPricingService _pricing;
|
||||||
|
private readonly IFifoCostingService _fifo;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly INumberSequenceService _numbers;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public BundleSaleService(
|
||||||
|
IRepository<BundleSale> bundles,
|
||||||
|
IRepository<BundleSaleTemplate> templates,
|
||||||
|
IRepository<Customer> customers,
|
||||||
|
IRepository<Item> items,
|
||||||
|
IRepository<Uom> uoms,
|
||||||
|
IRepository<Warehouse> warehouses,
|
||||||
|
IRepository<User> users,
|
||||||
|
ISalesPricingService pricing,
|
||||||
|
IFifoCostingService fifo,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
INumberSequenceService numbers,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_templates = templates;
|
||||||
|
_bundles = bundles;
|
||||||
|
_customers = customers;
|
||||||
|
_items = items;
|
||||||
|
_uoms = uoms;
|
||||||
|
_warehouses = warehouses;
|
||||||
|
_users = users;
|
||||||
|
_pricing = pricing;
|
||||||
|
_fifo = fifo;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_numbers = numbers;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<BundleSaleTemplate> q = _templates.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(x => EF.Functions.ILike(x.TemplateCode, $"%{term}%") || EF.Functions.ILike(x.TemplateName, $"%{term}%") || EF.Functions.ILike(x.Description ?? "", $"%{term}%"));
|
||||||
|
}
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(x => x.BundleSaleTemplateId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
return PagedResponse<BundleSaleTemplateSummaryDto>.Create(rows.Select(x => new BundleSaleTemplateSummaryDto(
|
||||||
|
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.Status, x.Lines.Count, x.CreatedAt, x.UpdatedAt)).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleTemplateId == bundleSaleTemplateId, ct);
|
||||||
|
return template is null ? null : new BundleSaleTemplateDto(
|
||||||
|
template.BundleSaleTemplateId,
|
||||||
|
template.TemplateCode,
|
||||||
|
template.TemplateName,
|
||||||
|
template.Description,
|
||||||
|
template.Status,
|
||||||
|
template.CreatedAt,
|
||||||
|
template.UpdatedAt,
|
||||||
|
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
|
||||||
|
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
|
||||||
|
}
|
||||||
|
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||||
|
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderByDescending(x => x.BundleSaleId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||||
|
return PagedResponse<BundleSaleSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct);
|
||||||
|
return bundle is null ? null : Map(bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bundle = await _bundles.Query().AsNoTracking().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||||
|
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||||
|
if (bundle.Status != BundleSaleStatus.Draft)
|
||||||
|
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, false, Array.Empty<BundleSalePostingIssueDto>());
|
||||||
|
|
||||||
|
var issues = new List<BundleSalePostingIssueDto>();
|
||||||
|
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
|
||||||
|
{
|
||||||
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
|
if (available >= line.Qty) continue;
|
||||||
|
var item = await _items.Query().AsNoTracking().Where(x => x.ItemId == line.ItemId).Select(x => new { x.Sku, x.Name }).FirstAsync(ct);
|
||||||
|
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||||
|
}
|
||||||
|
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, ct);
|
||||||
|
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
|
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||||
|
var bundle = new BundleSale
|
||||||
|
{
|
||||||
|
BundleNo = await _numbers.NextAsync(DocumentTypes.BundleSale, ct),
|
||||||
|
BundleDate = DateTime.UtcNow,
|
||||||
|
CustomerId = request.CustomerId,
|
||||||
|
WarehouseId = request.WarehouseId,
|
||||||
|
CashierUserId = request.CashierUserId,
|
||||||
|
BundleSaleTemplateId = request.BundleSaleTemplateId,
|
||||||
|
BundleName = request.BundleName,
|
||||||
|
BundleCode = string.Empty,
|
||||||
|
Status = BundleSaleStatus.Draft,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
|
bundle.Lines = await BuildLinesAsync(template, request.Lines, ct);
|
||||||
|
Recalculate(bundle, request.BundlePrice);
|
||||||
|
bundle.BundleCode = $"{bundle.BundleNo}-B";
|
||||||
|
await _bundles.AddAsync(bundle, ct);
|
||||||
|
bundle.ConcurrencyStamp = 1;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
return Map(bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||||
|
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||||
|
if (bundle.Status != BundleSaleStatus.Draft)
|
||||||
|
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be edited.");
|
||||||
|
|
||||||
|
await ValidateReferencesAsync(request.CustomerId, request.WarehouseId, request.CashierUserId, request.BundleSaleTemplateId, ct);
|
||||||
|
var template = await _templates.Query().AsNoTracking().Include(x => x.Lines)
|
||||||
|
.FirstAsync(x => x.BundleSaleTemplateId == request.BundleSaleTemplateId, ct);
|
||||||
|
bundle.CustomerId = request.CustomerId;
|
||||||
|
bundle.WarehouseId = request.WarehouseId;
|
||||||
|
bundle.CashierUserId = request.CashierUserId;
|
||||||
|
bundle.BundleSaleTemplateId = request.BundleSaleTemplateId;
|
||||||
|
bundle.BundleName = request.BundleName;
|
||||||
|
bundle.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
|
||||||
|
bundle.Lines.Clear();
|
||||||
|
foreach (var line in await BuildLinesAsync(template, request.Lines, ct)) bundle.Lines.Add(line);
|
||||||
|
Recalculate(bundle, request.BundlePrice);
|
||||||
|
bundle.UpdatedAt = DateTime.UtcNow;
|
||||||
|
bundle.ConcurrencyStamp++;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
return Map(bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||||
|
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||||
|
if (bundle.Status != BundleSaleStatus.Draft)
|
||||||
|
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be posted.");
|
||||||
|
|
||||||
|
var check = await CheckPostingAsync(bundleSaleId, ct);
|
||||||
|
if (!check.CanPost)
|
||||||
|
throw new ConflictException("Resolve stock shortages before posting this bundle sale.");
|
||||||
|
|
||||||
|
var posted = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
|
{
|
||||||
|
foreach (var line in bundle.Lines.Where(l => l.IncludeInBundle))
|
||||||
|
{
|
||||||
|
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, null, line.Qty, token);
|
||||||
|
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||||
|
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, null, null, _currentUser.AuditUserId,
|
||||||
|
Direction.Out, line.Qty, cost, 0m, nameof(BundleSale), bundle.BundleSaleId, DateTime.UtcNow, token);
|
||||||
|
}
|
||||||
|
bundle.Status = BundleSaleStatus.Posted;
|
||||||
|
bundle.UpdatedAt = DateTime.UtcNow;
|
||||||
|
bundle.ConcurrencyStamp++;
|
||||||
|
return bundle;
|
||||||
|
}, ct);
|
||||||
|
return Map(posted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bundle = await _bundles.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.BundleSaleId == bundleSaleId, ct)
|
||||||
|
?? throw new NotFoundException($"Bundle sale {bundleSaleId} was not found.");
|
||||||
|
if (bundle.Status != BundleSaleStatus.Draft)
|
||||||
|
throw new ConflictException($"Bundle sale {bundleSaleId} is {bundle.Status} and cannot be cancelled.");
|
||||||
|
bundle.Status = BundleSaleStatus.Cancelled;
|
||||||
|
bundle.UpdatedAt = DateTime.UtcNow;
|
||||||
|
bundle.ConcurrencyStamp++;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
return Map(bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ValidateReferencesAsync(int customerId, int warehouseId, int cashierUserId, int templateId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!await _customers.Query().AnyAsync(x => x.CustomerId == customerId, ct))
|
||||||
|
throw new NotFoundException($"Customer {customerId} was not found.");
|
||||||
|
if (!await _warehouses.Query().AnyAsync(x => x.WarehouseId == warehouseId, ct))
|
||||||
|
throw new NotFoundException($"Warehouse {warehouseId} was not found.");
|
||||||
|
if (!await _users.Query().AnyAsync(x => x.UserId == cashierUserId, ct))
|
||||||
|
throw new NotFoundException($"User {cashierUserId} was not found.");
|
||||||
|
if (!await _templates.Query().AnyAsync(x => x.BundleSaleTemplateId == templateId, ct))
|
||||||
|
throw new NotFoundException($"Bundle template {templateId} was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<BundleSaleLine>> BuildLinesAsync(BundleSaleTemplate template, IReadOnlyList<CreateBundleSaleTemplateLineRequest> requestLines, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lines = new List<BundleSaleLine>();
|
||||||
|
var sourceLines = requestLines.Count > 0
|
||||||
|
? requestLines.OrderBy(x => x.SortOrder).ToList()
|
||||||
|
: template.Lines.OrderBy(x => x.SortOrder).Select(x => new CreateBundleSaleTemplateLineRequest
|
||||||
|
{
|
||||||
|
ItemId = x.ItemId,
|
||||||
|
UomId = x.UomId,
|
||||||
|
WarehouseId = x.WarehouseId,
|
||||||
|
Qty = x.Qty,
|
||||||
|
UnitPrice = x.UnitPrice,
|
||||||
|
IncludeInBundle = x.IncludeInBundle,
|
||||||
|
SortOrder = x.SortOrder
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
foreach (var r in sourceLines)
|
||||||
|
{
|
||||||
|
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||||
|
var resolved = await _pricing.ResolveAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
||||||
|
lines.Add(new BundleSaleLine
|
||||||
|
{
|
||||||
|
ItemId = r.ItemId,
|
||||||
|
Description = item.Name,
|
||||||
|
Qty = r.Qty,
|
||||||
|
UomId = r.UomId,
|
||||||
|
WarehouseId = r.WarehouseId,
|
||||||
|
UnitPrice = resolved.UnitPrice,
|
||||||
|
LineTotal = r.Qty * resolved.UnitPrice,
|
||||||
|
IncludeInBundle = r.IncludeInBundle,
|
||||||
|
IsComponent = true,
|
||||||
|
ParentLineId = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Recalculate(BundleSale bundle, decimal bundlePrice)
|
||||||
|
{
|
||||||
|
bundle.ComponentSubtotal = bundle.Lines.Where(x => x.IncludeInBundle).Sum(x => x.LineTotal);
|
||||||
|
bundle.BundlePrice = bundlePrice;
|
||||||
|
bundle.MarginAmount = bundle.BundlePrice - bundle.ComponentSubtotal;
|
||||||
|
bundle.DiscountTotal = Math.Max(0m, bundle.ComponentSubtotal - bundle.BundlePrice);
|
||||||
|
bundle.TaxTotal = 0m;
|
||||||
|
bundle.GrandTotal = bundle.BundlePrice + bundle.TaxTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BundleSaleSummaryDto MapSummary(BundleSale x) => new(
|
||||||
|
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.GrandTotal, x.CreatedAt);
|
||||||
|
|
||||||
|
private static BundleSaleDto Map(BundleSale x) => new(
|
||||||
|
x.BundleSaleId, x.BundleNo, x.BundleDate, x.CustomerId, x.CustomerSnapshotName, x.WarehouseId, x.CashierUserId, x.BundleSaleTemplateId,
|
||||||
|
x.BundleName, x.BundleCode, x.Status, x.ComponentSubtotal, x.BundlePrice, x.MarginAmount, x.DiscountTotal, x.TaxTotal, x.GrandTotal,
|
||||||
|
x.CreatedAt, x.UpdatedAt,
|
||||||
|
x.Lines.Select(l => new BundleSaleLineDto(l.BundleSaleLineId, l.ItemId, l.Description, l.Qty, l.UomId, l.WarehouseId, l.UnitPrice, l.LineTotal, l.IncludeInBundle, l.IsComponent, l.ParentLineId)).ToList());
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Sales;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface IBundleSaleService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<BundleSaleTemplateSummaryDto>> ListTemplatesAsync(PageQuery query, CancellationToken ct = default);
|
||||||
|
Task<BundleSaleTemplateDto?> GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default);
|
||||||
|
Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||||
|
Task<BundleSaleDto?> GetAsync(int bundleSaleId, CancellationToken ct = default);
|
||||||
|
Task<BundleSalePostingCheckDto> CheckPostingAsync(int bundleSaleId, CancellationToken ct = default);
|
||||||
|
Task<BundleSaleDto> CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default);
|
||||||
|
Task<BundleSaleDto> UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default);
|
||||||
|
Task<BundleSaleDto> PostAsync(int bundleSaleId, CancellationToken ct = default);
|
||||||
|
Task<BundleSaleDto> CancelAsync(int bundleSaleId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 14 · BACKEND — Sales API Reference
|
# 14 · BACKEND — Sales API Reference
|
||||||
|
|
||||||
> **Authoritative for:** sales API contracts for invoices, slips, free issues, and sales reports.
|
> **Authoritative for:** sales API contracts for invoices, slips, bundle sales, free issues, and sales reports.
|
||||||
> **Navigation:** start from `00-CORE.md`. Sales business rules live in `docs/SALES_MODULE_PLAN.md` and the sales-related backend progress is tracked in `Backend/PROGRESS.md`.
|
> **Navigation:** start from `00-CORE.md`. Sales business rules live in `docs/SALES_MODULE_PLAN.md` and the sales-related backend progress is tracked in `Backend/PROGRESS.md`.
|
||||||
> **Scope:** this document covers the sales endpoints currently implemented in ERPCore. Free issue is modeled as a sales-slip alias, not a separate table.
|
> **Scope:** this document covers the sales endpoints currently implemented in ERPCore. Free issue is modeled as a sales-slip alias, not a separate table.
|
||||||
|
|
||||||
@@ -132,9 +132,62 @@ Before posting, the UI calls `GET /api/v1/sales-slips/{salesSlipId}/posting-chec
|
|||||||
### `POST /api/v1/sales-slips/{salesSlipId}/cancel`
|
### `POST /api/v1/sales-slips/{salesSlipId}/cancel`
|
||||||
Cancels a draft slip.
|
Cancels a draft slip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bundle Sales
|
||||||
|
|
||||||
|
Bundle sales are a separate sales document family for fixed bundle compositions.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales`
|
||||||
|
Query:
|
||||||
|
- `page`
|
||||||
|
- `pageSize`
|
||||||
|
- `q`
|
||||||
|
- `customerId`
|
||||||
|
- `warehouseId`
|
||||||
|
|
||||||
|
Returns a paged list of `BundleSaleSummaryDto`.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Returns `BundleSaleDto`.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}/posting-check`
|
||||||
|
Validates component stock before posting.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales`
|
||||||
|
Creates a draft bundle sale from a fixed template.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerId": 2,
|
||||||
|
"warehouseId": 1,
|
||||||
|
"cashierUserId": 1,
|
||||||
|
"bundleSaleTemplateId": 1,
|
||||||
|
"bundleName": "Summer Promo Pack",
|
||||||
|
"bundleCode": "BND-001",
|
||||||
|
"bundlePrice": 2500,
|
||||||
|
"allowPriceOverride": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Updates a draft bundle sale. Requires `If-Match`.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/post`
|
||||||
|
Posts the bundle and consumes stock from the included component lines.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/cancel`
|
||||||
|
Cancels a draft bundle sale.
|
||||||
|
|
||||||
|
Business rule:
|
||||||
|
- bundle composition is fixed by template lines
|
||||||
|
- posting consumes component stock, not a synthetic bundle stock item
|
||||||
|
- print pages should show both the bundle summary and the component breakdown
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Free Issues
|
## 5. Free Issues
|
||||||
|
|
||||||
Free issue is a business alias over sales slips.
|
Free issue is a business alias over sales slips.
|
||||||
There is no separate free-issue table in the current schema.
|
There is no separate free-issue table in the current schema.
|
||||||
@@ -165,7 +218,7 @@ Business rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Sales Reports
|
## 6. Sales Reports
|
||||||
|
|
||||||
### `GET /api/v1/reports/sales`
|
### `GET /api/v1/reports/sales`
|
||||||
Returns the report catalog.
|
Returns the report catalog.
|
||||||
@@ -217,8 +270,9 @@ Validation rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Notes
|
## 7. Notes
|
||||||
|
|
||||||
- The sales report service reads both invoices and slips where relevant.
|
- The sales report service reads both invoices and slips where relevant.
|
||||||
- Free issue reporting is derived from the same sales document lines.
|
- Free issue reporting is derived from the same sales document lines.
|
||||||
|
- Bundle sales are treated as a separate fixed-composition document family.
|
||||||
- There is no separate free-issue table in the current schema.
|
- There is no separate free-issue table in the current schema.
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 15 · BACKEND — Bundle Sales API
|
||||||
|
|
||||||
|
> **Authoritative for:** fixed-composition bundle sales, templates, posting, and print data.
|
||||||
|
> **Navigation:** start from `00-CORE.md`. This module follows the same repository/UoW/ETag/audit patterns as invoices and slips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Concept
|
||||||
|
|
||||||
|
Bundle sales are a separate sales document family for fixed bundle compositions.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- a bundle sale is created from a bundle template
|
||||||
|
- the bundle template defines fixed component stock lines
|
||||||
|
- posting consumes stock from the component items, not from a synthetic bundle SKU
|
||||||
|
- the bundle header carries the commercial sale value
|
||||||
|
- print views show both bundle summary and component breakdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales`
|
||||||
|
Query:
|
||||||
|
- `page`
|
||||||
|
- `pageSize`
|
||||||
|
- `q`
|
||||||
|
- `customerId`
|
||||||
|
- `warehouseId`
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Returns the bundle sale header and all component lines.
|
||||||
|
|
||||||
|
### `GET /api/v1/bundle-sales/{bundleSaleId}/posting-check`
|
||||||
|
Validates component stock before posting.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales`
|
||||||
|
Creates a draft bundle sale from a fixed bundle template.
|
||||||
|
|
||||||
|
### `PUT /api/v1/bundle-sales/{bundleSaleId}`
|
||||||
|
Updates a draft bundle sale. Requires `If-Match`.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/post`
|
||||||
|
Consumes stock from included component lines and marks the bundle as posted.
|
||||||
|
|
||||||
|
### `POST /api/v1/bundle-sales/{bundleSaleId}/cancel`
|
||||||
|
Cancels a draft bundle sale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Template Rules
|
||||||
|
|
||||||
|
- bundle templates are fixed in composition
|
||||||
|
- each template line maps to one component stock item
|
||||||
|
- component quantities are expanded into the sale draft at creation time
|
||||||
|
- price override is allowed only when the caller is permitted by business rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Model
|
||||||
|
|
||||||
|
### `BundleSaleTemplate`
|
||||||
|
- `BundleSaleTemplateId`
|
||||||
|
- `TemplateCode`
|
||||||
|
- `TemplateName`
|
||||||
|
- `Description`
|
||||||
|
- `Status`
|
||||||
|
- `CreatedAt`
|
||||||
|
- `UpdatedAt`
|
||||||
|
- `RowVersion`
|
||||||
|
|
||||||
|
### `BundleSaleTemplateLine`
|
||||||
|
- `BundleSaleTemplateLineId`
|
||||||
|
- `BundleSaleTemplateId`
|
||||||
|
- `ItemId`
|
||||||
|
- `UomId`
|
||||||
|
- `WarehouseId`
|
||||||
|
- `Qty`
|
||||||
|
- `UnitPrice`
|
||||||
|
- `IncludeInBundle`
|
||||||
|
- `SortOrder`
|
||||||
|
|
||||||
|
### `BundleSale`
|
||||||
|
- `BundleSaleId`
|
||||||
|
- `BundleNo`
|
||||||
|
- `BundleDate`
|
||||||
|
- `CustomerId`
|
||||||
|
- `CustomerSnapshotName`
|
||||||
|
- `WarehouseId`
|
||||||
|
- `CashierUserId`
|
||||||
|
- `BundleSaleTemplateId`
|
||||||
|
- `BundleName`
|
||||||
|
- `BundleCode`
|
||||||
|
- `Status`
|
||||||
|
- `ComponentSubtotal`
|
||||||
|
- `BundlePrice`
|
||||||
|
- `MarginAmount`
|
||||||
|
- `DiscountTotal`
|
||||||
|
- `TaxTotal`
|
||||||
|
- `GrandTotal`
|
||||||
|
- `CreatedAt`
|
||||||
|
- `UpdatedAt`
|
||||||
|
- `RowVersion`
|
||||||
|
|
||||||
|
### `BundleSaleLine`
|
||||||
|
- `BundleSaleLineId`
|
||||||
|
- `BundleSaleId`
|
||||||
|
- `ItemId`
|
||||||
|
- `Description`
|
||||||
|
- `Qty`
|
||||||
|
- `UomId`
|
||||||
|
- `WarehouseId`
|
||||||
|
- `UnitPrice`
|
||||||
|
- `LineTotal`
|
||||||
|
- `IncludeInBundle`
|
||||||
|
- `IsComponent`
|
||||||
|
- `ParentLineId`
|
||||||
|
- `RowVersion`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Posting
|
||||||
|
|
||||||
|
Posting behavior:
|
||||||
|
- validate each included component line has sufficient stock
|
||||||
|
- consume FIFO layers from the component items
|
||||||
|
- write stock ledger rows for each component
|
||||||
|
- mark the bundle as posted in the same transaction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Notes
|
||||||
|
|
||||||
|
- This module is intentionally separate from invoices and slips.
|
||||||
|
- Bundle sales are for fixed compositions only in this phase.
|
||||||
|
- Print views should mirror the existing sales document print behavior without the dashboard shell.
|
||||||
@@ -282,6 +282,8 @@ Add richer commercial features after Phase 1 is stable and tested.
|
|||||||
- price lists
|
- price lists
|
||||||
- promotions
|
- promotions
|
||||||
- free issue schemes
|
- free issue schemes
|
||||||
|
- bundle sales templates
|
||||||
|
- bundle sales documents
|
||||||
- reservations
|
- reservations
|
||||||
- payment allocation
|
- payment allocation
|
||||||
- approval flow
|
- approval flow
|
||||||
@@ -304,6 +306,18 @@ Promotional header.
|
|||||||
#### `PromotionRule`
|
#### `PromotionRule`
|
||||||
Buy-X-get-Y, discount, or reward rules.
|
Buy-X-get-Y, discount, or reward rules.
|
||||||
|
|
||||||
|
#### `BundleSaleTemplate`
|
||||||
|
Fixed bundle composition definition.
|
||||||
|
|
||||||
|
#### `BundleSaleTemplateLine`
|
||||||
|
Component stock items and quantities inside a bundle template.
|
||||||
|
|
||||||
|
#### `BundleSale`
|
||||||
|
Posted or draft bundle sale header.
|
||||||
|
|
||||||
|
#### `BundleSaleLine`
|
||||||
|
Component lines expanded from a bundle template.
|
||||||
|
|
||||||
#### `FreeIssueScheme`
|
#### `FreeIssueScheme`
|
||||||
Separate free issue header.
|
Separate free issue header.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user