diff --git a/Backend/ERPCore/Controllers/BundleSalesController.cs b/Backend/ERPCore/Controllers/BundleSalesController.cs new file mode 100644 index 0000000..6b45382 --- /dev/null +++ b/Backend/ERPCore/Controllers/BundleSalesController.cs @@ -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), StatusCodes.Status200OK)] + public async Task>> 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> GetTemplate(int bundleSaleTemplateId, CancellationToken ct) + { + var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> PostingCheck(int bundleSaleId, CancellationToken ct) + => Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct)); + + [HttpPost] + [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)] + public async Task> 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> 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> Post(int bundleSaleId, CancellationToken ct) + => Ok(await _bundles.PostAsync(bundleSaleId, ct)); + + [HttpPost("{bundleSaleId:int}/cancel")] + [ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)] + public async Task> Cancel(int bundleSaleId, CancellationToken ct) + => Ok(await _bundles.CancelAsync(bundleSaleId, ct)); +} diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs index 4e1f4ee..cbd91da 100644 --- a/Backend/ERPCore/Domain/DocumentTypes.cs +++ b/Backend/ERPCore/Domain/DocumentTypes.cs @@ -19,4 +19,5 @@ public static class DocumentTypes public const string Production = "PRD"; public const string SalesInvoice = "SI"; public const string SalesSlip = "SSL"; + public const string BundleSale = "BND"; } diff --git a/Backend/ERPCore/Domain/Entities/BundleSale.cs b/Backend/ERPCore/Domain/Entities/BundleSale.cs new file mode 100644 index 0000000..3129935 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSale.cs @@ -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 Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs new file mode 100644 index 0000000..fecf7ef --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSaleLine.cs @@ -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; } +} diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs b/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs new file mode 100644 index 0000000..0343a23 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSaleTemplate.cs @@ -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 Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs b/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs new file mode 100644 index 0000000..e41414b --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/BundleSaleTemplateLine.cs @@ -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; } +} diff --git a/Backend/ERPCore/Domain/Enums/BundleSaleStatus.cs b/Backend/ERPCore/Domain/Enums/BundleSaleStatus.cs new file mode 100644 index 0000000..a17d0eb --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/BundleSaleStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum BundleSaleStatus +{ + Draft = 0, + Posted = 1, + Cancelled = 2 +} diff --git a/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs new file mode 100644 index 0000000..7d14af7 --- /dev/null +++ b/Backend/ERPCore/Dtos/Sales/BundleSaleDtos.cs @@ -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 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 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 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 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 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 Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleConfiguration.cs new file mode 100644 index 0000000..d53b314 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs new file mode 100644 index 0000000..fac3e88 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleLineConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs new file mode 100644 index 0000000..e580e08 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs new file mode 100644 index 0000000..113647e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BundleSaleTemplateLineConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs index 4db1434..afc2771 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs @@ -38,7 +38,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration 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 = 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 } ); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs index 42e5b04..4282211 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs @@ -33,6 +33,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration - private static async Task SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct) - { - if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false; + //private static async Task SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct) + //{ + // if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false; - db.CompanyProfiles.Add(new CompanyProfile - { - CompanyProfileId = CompanyProfile.SingletonId, - LegalName = "ERP Core Trading (Pvt) Ltd", - TradeName = "ERP Core Trading", - TaxRegistrationNo = "TAX-DEFAULT-001", - VatRegistrationNo = "VAT-DEFAULT-001", - AddressLine1 = "1 Demo Street", - City = "Colombo", - Country = "Sri Lanka", - Phone = "+94 11 000 0000", - Email = "accounts@example.com", - BankName = "Demo Bank", - BankBranch = "Colombo Main", - AccountName = "ERP Core Trading (Pvt) Ltd", - AccountNumber = "000123456789", - SwiftCode = "DEMO1234", - FooterNote = "Thank you for your business." - }); - return true; - } + // db.CompanyProfiles.Add(new CompanyProfile + // { + // CompanyProfileId = CompanyProfile.SingletonId, + // LegalName = "ERP Core Trading (Pvt) Ltd", + // TradeName = "ERP Core Trading", + // TaxRegistrationNo = "TAX-DEFAULT-001", + // VatRegistrationNo = "VAT-DEFAULT-001", + // AddressLine1 = "1 Demo Street", + // City = "Colombo", + // Country = "Sri Lanka", + // Phone = "+94 11 000 0000", + // Email = "accounts@example.com", + // BankName = "Demo Bank", + // BankBranch = "Colombo Main", + // AccountName = "ERP Core Trading (Pvt) Ltd", + // AccountNumber = "000123456789", + // SwiftCode = "DEMO1234", + // FooterNote = "Thank you for your business." + // }); + // return true; + //} /// /// 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 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; } @@ -369,7 +378,7 @@ public static class DataSeeder { var year = DateTime.UtcNow.Year; 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) .ToListAsync(ct); var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -377,7 +386,8 @@ public static class DataSeeder var seeds = new[] { 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(); @@ -387,6 +397,209 @@ public static class DataSeeder return true; } + private static async Task 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 SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct) { if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct)) diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index c795331..556890c 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -90,6 +90,10 @@ public class ErpDbContext : DbContext public DbSet SalesInvoiceLines => Set(); public DbSet SalesSlips => Set(); public DbSet SalesSlipLines => Set(); + public DbSet BundleSaleTemplates => Set(); + public DbSet BundleSaleTemplateLines => Set(); + public DbSet BundleSales => Set(); + public DbSet BundleSaleLines => Set(); // --- Reference data (docs/10 Part C.7) --- public DbSet ReasonCodes => Set(); diff --git a/Backend/ERPCore/Services/BundleSaleService.cs b/Backend/ERPCore/Services/BundleSaleService.cs new file mode 100644 index 0000000..1532844 --- /dev/null +++ b/Backend/ERPCore/Services/BundleSaleService.cs @@ -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 _templates; + private readonly IRepository _bundles; + private readonly IRepository _customers; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly IRepository _users; + private readonly ISalesPricingService _pricing; + private readonly IFifoCostingService _fifo; + private readonly ICurrentUser _currentUser; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public BundleSaleService( + IRepository bundles, + IRepository templates, + IRepository customers, + IRepository items, + IRepository uoms, + IRepository warehouses, + IRepository 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> ListTemplatesAsync(PageQuery query, CancellationToken ct = default) + { + IQueryable 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.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 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> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default) + { + IQueryable 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.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task 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 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()); + + var issues = new List(); + 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 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 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 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 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> BuildLinesAsync(BundleSaleTemplate template, IReadOnlyList requestLines, CancellationToken ct) + { + var lines = new List(); + 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()); + +} diff --git a/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs b/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs new file mode 100644 index 0000000..b67ee2f --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IBundleSaleService.cs @@ -0,0 +1,18 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Sales; + +namespace ERPCore.Services.Interfaces; + +public interface IBundleSaleService +{ + Task> ListTemplatesAsync(PageQuery query, CancellationToken ct = default); + Task GetTemplateAsync(int bundleSaleTemplateId, CancellationToken ct = default); + Task> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int bundleSaleId, CancellationToken ct = default); + Task CheckPostingAsync(int bundleSaleId, CancellationToken ct = default); + Task CreateAsync(CreateBundleSaleRequest request, CancellationToken ct = default); + Task UpdateAsync(int bundleSaleId, UpdateBundleSaleRequest request, CancellationToken ct = default); + Task PostAsync(int bundleSaleId, CancellationToken ct = default); + Task CancelAsync(int bundleSaleId, CancellationToken ct = default); +} diff --git a/docs/14-BACKEND-SALES-API.md b/docs/14-BACKEND-SALES-API.md index a683434..a6a1b3b 100644 --- a/docs/14-BACKEND-SALES-API.md +++ b/docs/14-BACKEND-SALES-API.md @@ -1,6 +1,6 @@ # 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`. > **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` 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. 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` 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. - 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. diff --git a/docs/15-BACKEND-SALES-BUNDLES.md b/docs/15-BACKEND-SALES-BUNDLES.md new file mode 100644 index 0000000..40da017 --- /dev/null +++ b/docs/15-BACKEND-SALES-BUNDLES.md @@ -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. diff --git a/docs/SALES_MODULE_PLAN.md b/docs/SALES_MODULE_PLAN.md index fc2038d..861e318 100644 --- a/docs/SALES_MODULE_PLAN.md +++ b/docs/SALES_MODULE_PLAN.md @@ -282,6 +282,8 @@ Add richer commercial features after Phase 1 is stable and tested. - price lists - promotions - free issue schemes +- bundle sales templates +- bundle sales documents - reservations - payment allocation - approval flow @@ -304,6 +306,18 @@ Promotional header. #### `PromotionRule` 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` Separate free issue header.