sales fixers
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
+11572
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)");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user