feat: Add tiered packaging fee support for stores
Introduces tiered packaging fee configuration for stores by adding OrderPackagingFeeMode and PackagingFeeTiers fields to StoreFee. Updates DTOs, validators, handlers, and mapping logic to support both fixed and tiered packaging fee modes. Adds StoreFeeTierHelper for tier normalization and serialization, and includes a database migration to persist the new fields.
This commit is contained in:
@@ -29,11 +29,21 @@ public sealed record UpdateStoreFeeCommand : IRequest<StoreFeeDto>
|
||||
/// </summary>
|
||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单打包费规则。
|
||||
/// </summary>
|
||||
public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; } = OrderPackagingFeeMode.Fixed;
|
||||
|
||||
/// <summary>
|
||||
/// 固定打包费。
|
||||
/// </summary>
|
||||
public decimal? FixedPackagingFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 阶梯打包费配置。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreFeeTierDto> PackagingFeeTiers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费门槛。
|
||||
/// </summary>
|
||||
|
||||
@@ -42,6 +42,11 @@ public sealed record StoreFeeCalculationResultDto
|
||||
/// </summary>
|
||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单打包费规则。
|
||||
/// </summary>
|
||||
public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 打包费拆分明细。
|
||||
/// </summary>
|
||||
|
||||
@@ -36,11 +36,21 @@ public sealed record StoreFeeDto
|
||||
/// </summary>
|
||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单打包费规则。
|
||||
/// </summary>
|
||||
public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 固定打包费。
|
||||
/// </summary>
|
||||
public decimal FixedPackagingFee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 阶梯打包费配置。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreFeeTierDto> PackagingFeeTiers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 免配送费门槛。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 打包费阶梯配置 DTO。
|
||||
/// </summary>
|
||||
public sealed record StoreFeeTierDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 阶梯起始金额。
|
||||
/// </summary>
|
||||
public decimal MinPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 阶梯截止金额(为空表示无限)。
|
||||
/// </summary>
|
||||
public decimal? MaxPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 阶梯打包费。
|
||||
/// </summary>
|
||||
public decimal Fee { get; init; }
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public sealed class CalculateStoreFeeQueryHandler(
|
||||
MinimumOrderAmount = 0m,
|
||||
BaseDeliveryFee = 0m,
|
||||
PackagingFeeMode = PackagingFeeMode.Fixed,
|
||||
OrderPackagingFeeMode = OrderPackagingFeeMode.Fixed,
|
||||
FixedPackagingFee = 0m
|
||||
};
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ public sealed class CreateStoreCommandHandler(
|
||||
MinimumOrderAmount = 0m,
|
||||
BaseDeliveryFee = 0m,
|
||||
PackagingFeeMode = PackagingFeeMode.Fixed,
|
||||
OrderPackagingFeeMode = OrderPackagingFeeMode.Fixed,
|
||||
FixedPackagingFee = 0m
|
||||
}, cancellationToken);
|
||||
await storeRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -41,6 +41,7 @@ public sealed class GetStoreFeeQueryHandler(
|
||||
MinimumOrderAmount = 0m,
|
||||
BaseDeliveryFee = 0m,
|
||||
PackagingFeeMode = Domain.Stores.Enums.PackagingFeeMode.Fixed,
|
||||
OrderPackagingFeeMode = Domain.Stores.Enums.OrderPackagingFeeMode.Fixed,
|
||||
FixedPackagingFee = 0m
|
||||
};
|
||||
return StoreMapping.ToDto(fallback);
|
||||
|
||||
@@ -57,9 +57,22 @@ public sealed class UpdateStoreFeeCommandHandler(
|
||||
fee.MinimumOrderAmount = request.MinimumOrderAmount;
|
||||
fee.BaseDeliveryFee = request.DeliveryFee;
|
||||
fee.PackagingFeeMode = request.PackagingFeeMode;
|
||||
fee.FixedPackagingFee = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
||||
? request.FixedPackagingFee ?? 0m
|
||||
: 0m;
|
||||
fee.OrderPackagingFeeMode = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
||||
? request.OrderPackagingFeeMode
|
||||
: OrderPackagingFeeMode.Fixed;
|
||||
if (request.PackagingFeeMode == PackagingFeeMode.Fixed && request.OrderPackagingFeeMode == OrderPackagingFeeMode.Tiered)
|
||||
{
|
||||
var normalizedTiers = StoreFeeTierHelper.Normalize(request.PackagingFeeTiers);
|
||||
fee.FixedPackagingFee = 0m;
|
||||
fee.PackagingFeeTiersJson = StoreFeeTierHelper.Serialize(normalizedTiers);
|
||||
}
|
||||
else
|
||||
{
|
||||
fee.FixedPackagingFee = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
||||
? request.FixedPackagingFee ?? 0m
|
||||
: 0m;
|
||||
fee.PackagingFeeTiersJson = null;
|
||||
}
|
||||
fee.FreeDeliveryThreshold = request.FreeDeliveryThreshold;
|
||||
|
||||
// 4. (空行后) 保存并返回
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 打包费阶梯 JSON 辅助方法。
|
||||
/// </summary>
|
||||
public static class StoreFeeTierHelper
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IReadOnlyList<StoreFeeTierDto> Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<StoreFeeTierDto>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static string? Serialize(IReadOnlyList<StoreFeeTierDto> tiers)
|
||||
{
|
||||
if (tiers.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(tiers, JsonOptions);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<StoreFeeTierDto> Normalize(IReadOnlyList<StoreFeeTierDto> tiers)
|
||||
{
|
||||
if (tiers.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalized = new List<StoreFeeTierDto>(tiers.Count);
|
||||
var currentMin = 0m;
|
||||
foreach (var tier in tiers)
|
||||
{
|
||||
normalized.Add(new StoreFeeTierDto
|
||||
{
|
||||
MinPrice = currentMin,
|
||||
MaxPrice = tier.MaxPrice,
|
||||
Fee = tier.Fee
|
||||
});
|
||||
if (tier.MaxPrice.HasValue)
|
||||
{
|
||||
currentMin = tier.MaxPrice.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,9 @@ public static class StoreMapping
|
||||
MinimumOrderAmount = fee.MinimumOrderAmount,
|
||||
DeliveryFee = fee.BaseDeliveryFee,
|
||||
PackagingFeeMode = fee.PackagingFeeMode,
|
||||
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
|
||||
FixedPackagingFee = fee.FixedPackagingFee,
|
||||
PackagingFeeTiers = StoreFeeTierHelper.Deserialize(fee.PackagingFeeTiersJson),
|
||||
FreeDeliveryThreshold = fee.FreeDeliveryThreshold,
|
||||
CreatedAt = fee.CreatedAt,
|
||||
UpdatedAt = fee.UpdatedAt
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
|
||||
|
||||
RuleFor(x => x.FixedPackagingFee)
|
||||
.NotNull()
|
||||
.When(x => x.PackagingFeeMode == PackagingFeeMode.Fixed)
|
||||
.When(x => x.PackagingFeeMode == PackagingFeeMode.Fixed && x.OrderPackagingFeeMode == OrderPackagingFeeMode.Fixed)
|
||||
.WithMessage("总计打包费模式下必须填写固定打包费");
|
||||
|
||||
RuleFor(x => x.FixedPackagingFee)
|
||||
@@ -31,5 +31,57 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
|
||||
RuleFor(x => x.FixedPackagingFee)
|
||||
.Must(fee => !fee.HasValue || fee.Value >= 0)
|
||||
.WithMessage("固定打包费不能为负数");
|
||||
|
||||
RuleFor(x => x)
|
||||
.Custom((command, context) =>
|
||||
{
|
||||
if (command.PackagingFeeMode != PackagingFeeMode.Fixed || command.OrderPackagingFeeMode != OrderPackagingFeeMode.Tiered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.PackagingFeeTiers is null || command.PackagingFeeTiers.Count == 0)
|
||||
{
|
||||
context.AddFailure("阶梯价模式必须配置至少 1 个区间");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.PackagingFeeTiers.Count > 10)
|
||||
{
|
||||
context.AddFailure("阶梯价最多支持 10 个区间");
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedMin = 0m;
|
||||
for (var index = 0; index < command.PackagingFeeTiers.Count; index++)
|
||||
{
|
||||
var tier = command.PackagingFeeTiers[index];
|
||||
if (tier.Fee < 0 || tier.Fee > 99.99m)
|
||||
{
|
||||
context.AddFailure($"第 {index + 1} 个阶梯打包费需在 0~99.99 之间");
|
||||
return;
|
||||
}
|
||||
|
||||
if (tier.MaxPrice.HasValue && tier.MaxPrice.Value <= expectedMin)
|
||||
{
|
||||
context.AddFailure($"第 {index + 1} 个阶梯上限必须大于 {expectedMin:0.##}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tier.MaxPrice.HasValue && index != command.PackagingFeeTiers.Count - 1)
|
||||
{
|
||||
context.AddFailure("仅允许最后一个阶梯的上限为空");
|
||||
return;
|
||||
}
|
||||
|
||||
if (tier.MaxPrice.HasValue && tier.MaxPrice.Value > 99999.99m)
|
||||
{
|
||||
context.AddFailure($"第 {index + 1} 个阶梯上限不能超过 99999.99");
|
||||
return;
|
||||
}
|
||||
|
||||
expectedMin = tier.MaxPrice ?? expectedMin;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user