sales fixers

This commit is contained in:
2026-10-21 10:45:19 +05:30
parent 47479fb4f2
commit a27bdaea64
14 changed files with 12580 additions and 832 deletions
@@ -8,6 +8,12 @@ public class BundleSaleTemplate
public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>
/// What the whole bundle sells for. On save it is split across the included lines' unit prices
/// (see BundleSaleService.AllocateBundlePrice), so selling the template still yields per-component prices.
/// 0 on templates created before bundle prices existed — their line prices stand as entered.
/// </summary>
public decimal BundlePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
+8 -2
View File
@@ -24,11 +24,11 @@ public sealed record BundleSaleTemplateLineDto(
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record BundleSalePostingIssueDto(
@@ -53,6 +53,12 @@ public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>
/// The whole bundle's selling price. When set, it replaces the lines' UnitPrice: it is split across the
/// included lines in proportion to their UnitPrice × Qty (sent as a weight, e.g. the item's normal price),
/// or by Qty when no weights are given. Omit to keep the per-line prices as sent.
/// </summary>
[Range(0, double.MaxValue)] public decimal? BundlePrice { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
@@ -16,6 +16,7 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
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.BundlePrice).HasPrecision(18, 2).HasDefaultValue(0m);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBundleTemplatePrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "BundlePrice",
table: "bundle_sale_templates",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BundlePrice",
table: "bundle_sale_templates");
}
}
}
@@ -794,6 +794,12 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("BundleSaleTemplateId"));
b.Property<decimal>("BundlePrice")
.ValueGeneratedOnAdd()
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)")
.HasDefaultValue(0m);
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
+37 -2
View File
@@ -67,7 +67,7 @@ public sealed class BundleSaleService : IBundleSaleService
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);
x.BundleSaleTemplateId, x.TemplateCode, x.TemplateName, x.Description, x.BundlePrice, 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)
@@ -78,6 +78,7 @@ public sealed class BundleSaleService : IBundleSaleService
template.TemplateCode,
template.TemplateName,
template.Description,
template.BundlePrice,
template.Status,
template.CreatedAt,
template.UpdatedAt,
@@ -92,10 +93,18 @@ public sealed class BundleSaleService : IBundleSaleService
TemplateCode = await _numbers.NextAsync(DocumentTypes.BundleSaleTemplate, ct),
TemplateName = request.TemplateName,
Description = request.Description,
BundlePrice = request.BundlePrice ?? 0m,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
if (request.BundlePrice is { } bundlePrice)
{
if (!request.Lines.Any(l => l.IncludeInBundle))
throw new DomainException(ErrorCodes.Validation, "A bundle needs at least one included line to carry its price.", 422);
AllocateBundlePrice(request.Lines, bundlePrice);
}
foreach (var line in request.Lines)
{
if (!await _items.Query().AsNoTracking().AnyAsync(x => x.ItemId == line.ItemId, ct))
@@ -118,12 +127,38 @@ public sealed class BundleSaleService : IBundleSaleService
await _uow.SaveChangesAsync(ct);
return new BundleSaleTemplateDto(
template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description,
template.BundleSaleTemplateId, template.TemplateCode, template.TemplateName, template.Description, template.BundlePrice,
template.Status, template.CreatedAt, template.UpdatedAt,
template.Lines.OrderBy(x => x.SortOrder).Select(x => new BundleSaleTemplateLineDto(
x.BundleSaleTemplateLineId, x.ItemId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
}
/// <summary>
/// Splits the bundle price over the included lines' unit prices, in proportion to each line's
/// UnitPrice × Qty as sent (the items' normal value), or to Qty when no line carries a price.
/// Line totals are rounded to 2 dp with the last included line taking the rounding remainder, so the
/// totals add up to the bundle price; excluded lines are priced 0.
/// </summary>
private static void AllocateBundlePrice(List<CreateBundleSaleTemplateLineRequest> lines, decimal bundlePrice)
{
var included = lines.Where(l => l.IncludeInBundle).ToList();
var weights = included.Select(l => l.UnitPrice * l.Qty).ToList();
if (weights.Sum() <= 0) weights = included.Select(l => l.Qty).ToList();
var totalWeight = weights.Sum();
var allocated = 0m;
for (var i = 0; i < included.Count; i++)
{
var lineTotal = i == included.Count - 1
? bundlePrice - allocated
: Math.Round(bundlePrice * weights[i] / totalWeight, 2, MidpointRounding.AwayFromZero);
allocated += lineTotal;
included[i].UnitPrice = Math.Round(lineTotal / included[i].Qty, 4, MidpointRounding.AwayFromZero);
}
foreach (var line in lines.Where(l => !l.IncludeInBundle)) line.UnitPrice = 0m;
}
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
{
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);