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>
|
/// </summary>
|
||||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单打包费规则。
|
||||||
|
/// </summary>
|
||||||
|
public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; } = OrderPackagingFeeMode.Fixed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 固定打包费。
|
/// 固定打包费。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? FixedPackagingFee { get; init; }
|
public decimal? FixedPackagingFee { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 阶梯打包费配置。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<StoreFeeTierDto> PackagingFeeTiers { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 免配送费门槛。
|
/// 免配送费门槛。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ public sealed record StoreFeeCalculationResultDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单打包费规则。
|
||||||
|
/// </summary>
|
||||||
|
public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 打包费拆分明细。
|
/// 打包费拆分明细。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -36,11 +36,21 @@ public sealed record StoreFeeDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public PackagingFeeMode PackagingFeeMode { get; init; }
|
public PackagingFeeMode PackagingFeeMode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单打包费规则。
|
||||||
|
/// </summary>
|
||||||
|
public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 固定打包费。
|
/// 固定打包费。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal FixedPackagingFee { get; init; }
|
public decimal FixedPackagingFee { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 阶梯打包费配置。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<StoreFeeTierDto> PackagingFeeTiers { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 免配送费门槛。
|
/// 免配送费门槛。
|
||||||
/// </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,
|
MinimumOrderAmount = 0m,
|
||||||
BaseDeliveryFee = 0m,
|
BaseDeliveryFee = 0m,
|
||||||
PackagingFeeMode = PackagingFeeMode.Fixed,
|
PackagingFeeMode = PackagingFeeMode.Fixed,
|
||||||
|
OrderPackagingFeeMode = OrderPackagingFeeMode.Fixed,
|
||||||
FixedPackagingFee = 0m
|
FixedPackagingFee = 0m
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ public sealed class CreateStoreCommandHandler(
|
|||||||
MinimumOrderAmount = 0m,
|
MinimumOrderAmount = 0m,
|
||||||
BaseDeliveryFee = 0m,
|
BaseDeliveryFee = 0m,
|
||||||
PackagingFeeMode = PackagingFeeMode.Fixed,
|
PackagingFeeMode = PackagingFeeMode.Fixed,
|
||||||
|
OrderPackagingFeeMode = OrderPackagingFeeMode.Fixed,
|
||||||
FixedPackagingFee = 0m
|
FixedPackagingFee = 0m
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
await storeRepository.SaveChangesAsync(cancellationToken);
|
await storeRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public sealed class GetStoreFeeQueryHandler(
|
|||||||
MinimumOrderAmount = 0m,
|
MinimumOrderAmount = 0m,
|
||||||
BaseDeliveryFee = 0m,
|
BaseDeliveryFee = 0m,
|
||||||
PackagingFeeMode = Domain.Stores.Enums.PackagingFeeMode.Fixed,
|
PackagingFeeMode = Domain.Stores.Enums.PackagingFeeMode.Fixed,
|
||||||
|
OrderPackagingFeeMode = Domain.Stores.Enums.OrderPackagingFeeMode.Fixed,
|
||||||
FixedPackagingFee = 0m
|
FixedPackagingFee = 0m
|
||||||
};
|
};
|
||||||
return StoreMapping.ToDto(fallback);
|
return StoreMapping.ToDto(fallback);
|
||||||
|
|||||||
@@ -57,9 +57,22 @@ public sealed class UpdateStoreFeeCommandHandler(
|
|||||||
fee.MinimumOrderAmount = request.MinimumOrderAmount;
|
fee.MinimumOrderAmount = request.MinimumOrderAmount;
|
||||||
fee.BaseDeliveryFee = request.DeliveryFee;
|
fee.BaseDeliveryFee = request.DeliveryFee;
|
||||||
fee.PackagingFeeMode = request.PackagingFeeMode;
|
fee.PackagingFeeMode = request.PackagingFeeMode;
|
||||||
fee.FixedPackagingFee = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
fee.OrderPackagingFeeMode = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
||||||
? request.FixedPackagingFee ?? 0m
|
? request.OrderPackagingFeeMode
|
||||||
: 0m;
|
: 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;
|
fee.FreeDeliveryThreshold = request.FreeDeliveryThreshold;
|
||||||
|
|
||||||
// 4. (空行后) 保存并返回
|
// 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,
|
MinimumOrderAmount = fee.MinimumOrderAmount,
|
||||||
DeliveryFee = fee.BaseDeliveryFee,
|
DeliveryFee = fee.BaseDeliveryFee,
|
||||||
PackagingFeeMode = fee.PackagingFeeMode,
|
PackagingFeeMode = fee.PackagingFeeMode,
|
||||||
|
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
|
||||||
FixedPackagingFee = fee.FixedPackagingFee,
|
FixedPackagingFee = fee.FixedPackagingFee,
|
||||||
|
PackagingFeeTiers = StoreFeeTierHelper.Deserialize(fee.PackagingFeeTiersJson),
|
||||||
FreeDeliveryThreshold = fee.FreeDeliveryThreshold,
|
FreeDeliveryThreshold = fee.FreeDeliveryThreshold,
|
||||||
CreatedAt = fee.CreatedAt,
|
CreatedAt = fee.CreatedAt,
|
||||||
UpdatedAt = fee.UpdatedAt
|
UpdatedAt = fee.UpdatedAt
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
|
|||||||
|
|
||||||
RuleFor(x => x.FixedPackagingFee)
|
RuleFor(x => x.FixedPackagingFee)
|
||||||
.NotNull()
|
.NotNull()
|
||||||
.When(x => x.PackagingFeeMode == PackagingFeeMode.Fixed)
|
.When(x => x.PackagingFeeMode == PackagingFeeMode.Fixed && x.OrderPackagingFeeMode == OrderPackagingFeeMode.Fixed)
|
||||||
.WithMessage("总计打包费模式下必须填写固定打包费");
|
.WithMessage("总计打包费模式下必须填写固定打包费");
|
||||||
|
|
||||||
RuleFor(x => x.FixedPackagingFee)
|
RuleFor(x => x.FixedPackagingFee)
|
||||||
@@ -31,5 +31,57 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator<UpdateSto
|
|||||||
RuleFor(x => x.FixedPackagingFee)
|
RuleFor(x => x.FixedPackagingFee)
|
||||||
.Must(fee => !fee.HasValue || fee.Value >= 0)
|
.Must(fee => !fee.HasValue || fee.Value >= 0)
|
||||||
.WithMessage("固定打包费不能为负数");
|
.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,21 @@ public sealed class StoreFee : MultiTenantEntityBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public PackagingFeeMode PackagingFeeMode { get; set; } = PackagingFeeMode.Fixed;
|
public PackagingFeeMode PackagingFeeMode { get; set; } = PackagingFeeMode.Fixed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单打包费规则(按订单收费时生效)。
|
||||||
|
/// </summary>
|
||||||
|
public OrderPackagingFeeMode OrderPackagingFeeMode { get; set; } = OrderPackagingFeeMode.Fixed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 固定打包费(总计模式有效)。
|
/// 固定打包费(总计模式有效)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal FixedPackagingFee { get; set; } = 0m;
|
public decimal FixedPackagingFee { get; set; } = 0m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 阶梯打包费配置(JSON)。
|
||||||
|
/// </summary>
|
||||||
|
public string? PackagingFeeTiersJson { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 免配送费门槛。
|
/// 免配送费门槛。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TakeoutSaaS.Domain.Stores.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订单打包费计算规则。
|
||||||
|
/// </summary>
|
||||||
|
public enum OrderPackagingFeeMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 一口价。
|
||||||
|
/// </summary>
|
||||||
|
Fixed = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 阶梯价。
|
||||||
|
/// </summary>
|
||||||
|
Tiered = 1
|
||||||
|
}
|
||||||
@@ -612,7 +612,9 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2);
|
builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2);
|
||||||
builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2);
|
builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2);
|
||||||
builder.Property(x => x.PackagingFeeMode).HasConversion<int>();
|
builder.Property(x => x.PackagingFeeMode).HasConversion<int>();
|
||||||
|
builder.Property(x => x.OrderPackagingFeeMode).HasConversion<int>();
|
||||||
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
|
builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2);
|
||||||
|
builder.Property(x => x.PackagingFeeTiersJson).HasColumnType("text");
|
||||||
builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2);
|
builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2);
|
||||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||||
builder.HasIndex(x => x.TenantId);
|
builder.HasIndex(x => x.TenantId);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||||
using TakeoutSaaS.Application.App.Stores.Services;
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
@@ -10,6 +12,8 @@ namespace TakeoutSaaS.Infrastructure.App.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DeliveryZoneService : IDeliveryZoneService
|
public sealed class DeliveryZoneService : IDeliveryZoneService
|
||||||
{
|
{
|
||||||
|
private const double CoordinateTolerance = 1e-6;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public StoreDeliveryCheckResultDto CheckPointInZones(
|
public StoreDeliveryCheckResultDto CheckPointInZones(
|
||||||
IReadOnlyList<StoreDeliveryZone> zones,
|
IReadOnlyList<StoreDeliveryZone> zones,
|
||||||
@@ -24,12 +28,16 @@ public sealed class DeliveryZoneService : IDeliveryZoneService
|
|||||||
// 2. (空行后) 逐个检测多边形命中
|
// 2. (空行后) 逐个检测多边形命中
|
||||||
foreach (var zone in zones)
|
foreach (var zone in zones)
|
||||||
{
|
{
|
||||||
if (!TryReadPolygon(zone.PolygonGeoJson, out var polygon))
|
if (!TryReadPolygons(zone.PolygonGeoJson, out var polygons))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (IsPointInPolygon(polygon, longitude, latitude))
|
foreach (var polygon in polygons)
|
||||||
{
|
{
|
||||||
|
if (!IsPointInPolygon(polygon, longitude, latitude))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return new StoreDeliveryCheckResultDto
|
return new StoreDeliveryCheckResultDto
|
||||||
{
|
{
|
||||||
InRange = true,
|
InRange = true,
|
||||||
@@ -42,9 +50,9 @@ public sealed class DeliveryZoneService : IDeliveryZoneService
|
|||||||
return new StoreDeliveryCheckResultDto { InRange = false };
|
return new StoreDeliveryCheckResultDto { InRange = false };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryReadPolygon(string geoJson, out List<Point> polygon)
|
private static bool TryReadPolygons(string geoJson, out List<Polygon> polygons)
|
||||||
{
|
{
|
||||||
polygon = [];
|
polygons = [];
|
||||||
if (string.IsNullOrWhiteSpace(geoJson))
|
if (string.IsNullOrWhiteSpace(geoJson))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -52,41 +60,11 @@ public sealed class DeliveryZoneService : IDeliveryZoneService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var document = JsonDocument.Parse(geoJson);
|
using var document = JsonDocument.Parse(geoJson);
|
||||||
var root = document.RootElement;
|
if (!TryReadPolygons(document.RootElement, polygons))
|
||||||
if (root.ValueKind != JsonValueKind.Object)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!root.TryGetProperty("coordinates", out var coordinatesElement) || coordinatesElement.ValueKind != JsonValueKind.Array)
|
return polygons.Count > 0;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (coordinatesElement.GetArrayLength() == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
var ringElement = coordinatesElement[0];
|
|
||||||
if (ringElement.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
foreach (var pointElement in ringElement.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!pointElement[0].TryGetDouble(out var x) || !pointElement[1].TryGetDouble(out var y))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
polygon.Add(new Point(x, y));
|
|
||||||
}
|
|
||||||
if (polygon.Count >= 2 && AreSamePoint(polygon[0], polygon[^1]))
|
|
||||||
{
|
|
||||||
polygon.RemoveAt(polygon.Count - 1);
|
|
||||||
}
|
|
||||||
return polygon.Count >= 3;
|
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -94,28 +72,304 @@ public sealed class DeliveryZoneService : IDeliveryZoneService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsPointInPolygon(IReadOnlyList<Point> polygon, double x, double y)
|
private static bool TryReadPolygons(JsonElement root, ICollection<Polygon> polygons)
|
||||||
{
|
{
|
||||||
var inside = false;
|
if (root.ValueKind == JsonValueKind.String)
|
||||||
for (var i = 0; i < polygon.Count; i++)
|
|
||||||
{
|
{
|
||||||
var j = i == 0 ? polygon.Count - 1 : i - 1;
|
var inner = root.GetString();
|
||||||
var xi = polygon[i].Longitude;
|
if (string.IsNullOrWhiteSpace(inner))
|
||||||
var yi = polygon[i].Latitude;
|
{
|
||||||
var xj = polygon[j].Longitude;
|
return false;
|
||||||
var yj = polygon[j].Latitude;
|
}
|
||||||
var intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi + double.Epsilon) + xi);
|
using var innerDocument = JsonDocument.Parse(inner);
|
||||||
if (intersect)
|
return TryReadPolygons(innerDocument.RootElement, polygons);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, "type", out var typeElement)
|
||||||
|
|| typeElement.ValueKind != JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = typeElement.GetString();
|
||||||
|
if (string.Equals(type, "FeatureCollection", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, "features", out var featuresElement)
|
||||||
|
|| featuresElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var featureElement in featuresElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (featureElement.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TryGetPropertyIgnoreCase(featureElement, "geometry", out var geometryElement))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TryReadPolygons(geometryElement, polygons);
|
||||||
|
}
|
||||||
|
return polygons.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(type, "Feature", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, "geometry", out var geometryElement))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return TryReadPolygons(geometryElement, polygons);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(type, "Polygon", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, "coordinates", out var coordinatesElement))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!TryReadPolygonFromCoordinates(coordinatesElement, out var polygon))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
polygons.Add(polygon);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(type, "MultiPolygon", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!TryGetPropertyIgnoreCase(root, "coordinates", out var coordinatesElement)
|
||||||
|
|| coordinatesElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var polygonElement in coordinatesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!TryReadPolygonFromCoordinates(polygonElement, out var polygon))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
polygons.Add(polygon);
|
||||||
|
}
|
||||||
|
return polygons.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadPolygonFromCoordinates(JsonElement coordinatesElement, out Polygon polygon)
|
||||||
|
{
|
||||||
|
polygon = default;
|
||||||
|
if (!TryReadRings(coordinatesElement, out var rings) || rings.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var outer = rings[0];
|
||||||
|
if (outer.Count < 3)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var holes = rings.Count > 1 ? rings.Skip(1).ToList() : [];
|
||||||
|
polygon = new Polygon(outer, holes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadRings(JsonElement element, out List<List<Point>> rings)
|
||||||
|
{
|
||||||
|
rings = [];
|
||||||
|
if (element.ValueKind != JsonValueKind.Array || element.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPositionArray(element[0]))
|
||||||
|
{
|
||||||
|
if (!TryReadRing(element, out var ring))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rings.Add(ring);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var ringElement in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!TryReadRing(ringElement, out var ring))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rings.Add(ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rings.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadRing(JsonElement ringElement, out List<Point> ring)
|
||||||
|
{
|
||||||
|
ring = [];
|
||||||
|
if (ringElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pointElement in ringElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!TryReadPosition(pointElement, out var point))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ring.Add(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ring.Count >= 2 && AreSamePoint(ring[0], ring[^1]))
|
||||||
|
{
|
||||||
|
ring.RemoveAt(ring.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ring.Count >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadPosition(JsonElement pointElement, out Point point)
|
||||||
|
{
|
||||||
|
point = default;
|
||||||
|
if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetCoordinate(pointElement[0], out var longitude)
|
||||||
|
|| !TryGetCoordinate(pointElement[1], out var latitude))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
point = new Point(longitude, latitude);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetCoordinate(JsonElement element, out double value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (element.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
return element.TryGetDouble(out value);
|
||||||
|
}
|
||||||
|
if (element.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPositionArray(JsonElement element)
|
||||||
|
=> element.ValueKind == JsonValueKind.Array
|
||||||
|
&& element.GetArrayLength() >= 2
|
||||||
|
&& TryGetCoordinate(element[0], out _)
|
||||||
|
&& TryGetCoordinate(element[1], out _);
|
||||||
|
|
||||||
|
private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(propertyName, out value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = property.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPointInPolygon(Polygon polygon, double x, double y)
|
||||||
|
{
|
||||||
|
if (!IsPointInRing(polygon.Outer, x, y))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var hole in polygon.Holes)
|
||||||
|
{
|
||||||
|
if (IsPointInRing(hole, x, y))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPointInRing(IReadOnlyList<Point> ring, double x, double y)
|
||||||
|
{
|
||||||
|
if (IsPointOnBoundary(ring, x, y))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inside = false;
|
||||||
|
for (var i = 0; i < ring.Count; i++)
|
||||||
|
{
|
||||||
|
var j = i == 0 ? ring.Count - 1 : i - 1;
|
||||||
|
var xi = ring[i].Longitude;
|
||||||
|
var yi = ring[i].Latitude;
|
||||||
|
var xj = ring[j].Longitude;
|
||||||
|
var yj = ring[j].Latitude;
|
||||||
|
var intersects = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi + double.Epsilon) + xi);
|
||||||
|
if (intersects)
|
||||||
{
|
{
|
||||||
inside = !inside;
|
inside = !inside;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return inside;
|
return inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsPointOnBoundary(IReadOnlyList<Point> ring, double x, double y)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < ring.Count; i++)
|
||||||
|
{
|
||||||
|
var j = i == ring.Count - 1 ? 0 : i + 1;
|
||||||
|
if (IsPointOnSegment(ring[i], ring[j], x, y))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPointOnSegment(Point start, Point end, double x, double y)
|
||||||
|
{
|
||||||
|
var cross = (end.Longitude - start.Longitude) * (y - start.Latitude)
|
||||||
|
- (end.Latitude - start.Latitude) * (x - start.Longitude);
|
||||||
|
if (Math.Abs(cross) > CoordinateTolerance)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dot = (x - start.Longitude) * (x - end.Longitude)
|
||||||
|
+ (y - start.Latitude) * (y - end.Latitude);
|
||||||
|
return dot <= CoordinateTolerance;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool AreSamePoint(Point first, Point second)
|
private static bool AreSamePoint(Point first, Point second)
|
||||||
=> Math.Abs(first.Longitude - second.Longitude) <= 1e-6
|
=> Math.Abs(first.Longitude - second.Longitude) <= CoordinateTolerance
|
||||||
&& Math.Abs(first.Latitude - second.Latitude) <= 1e-6;
|
&& Math.Abs(first.Latitude - second.Latitude) <= CoordinateTolerance;
|
||||||
|
|
||||||
private readonly record struct Point(double Longitude, double Latitude);
|
private readonly record struct Point(double Longitude, double Latitude);
|
||||||
|
|
||||||
|
private sealed record Polygon(IReadOnlyList<Point> Outer, IReadOnlyList<IReadOnlyList<Point>> Holes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using TakeoutSaaS.Application.App.Stores;
|
||||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||||
using TakeoutSaaS.Application.App.Stores.Services;
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
using TakeoutSaaS.Domain.Stores.Entities;
|
using TakeoutSaaS.Domain.Stores.Entities;
|
||||||
@@ -31,6 +32,7 @@ public sealed class StoreFeeCalculationService : IStoreFeeCalculationService
|
|||||||
DeliveryFee = 0m,
|
DeliveryFee = 0m,
|
||||||
PackagingFee = 0m,
|
PackagingFee = 0m,
|
||||||
PackagingFeeMode = fee.PackagingFeeMode,
|
PackagingFeeMode = fee.PackagingFeeMode,
|
||||||
|
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
|
||||||
TotalFee = 0m,
|
TotalFee = 0m,
|
||||||
TotalAmount = request.OrderAmount,
|
TotalAmount = request.OrderAmount,
|
||||||
Message = message
|
Message = message
|
||||||
@@ -49,7 +51,19 @@ public sealed class StoreFeeCalculationService : IStoreFeeCalculationService
|
|||||||
IReadOnlyList<StoreFeeCalculationBreakdownDto>? breakdown = null;
|
IReadOnlyList<StoreFeeCalculationBreakdownDto>? breakdown = null;
|
||||||
if (fee.PackagingFeeMode == PackagingFeeMode.Fixed)
|
if (fee.PackagingFeeMode == PackagingFeeMode.Fixed)
|
||||||
{
|
{
|
||||||
packagingFee = fee.FixedPackagingFee;
|
if (fee.OrderPackagingFeeMode == OrderPackagingFeeMode.Tiered)
|
||||||
|
{
|
||||||
|
var tiers = StoreFeeTierHelper.Deserialize(fee.PackagingFeeTiersJson);
|
||||||
|
if (tiers.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.ValidationFailed, "阶梯打包费配置缺失");
|
||||||
|
}
|
||||||
|
packagingFee = ResolveTieredFee(request.OrderAmount, tiers);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
packagingFee = fee.FixedPackagingFee;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -84,9 +98,28 @@ public sealed class StoreFeeCalculationService : IStoreFeeCalculationService
|
|||||||
DeliveryFee = deliveryFee,
|
DeliveryFee = deliveryFee,
|
||||||
PackagingFee = packagingFee,
|
PackagingFee = packagingFee,
|
||||||
PackagingFeeMode = fee.PackagingFeeMode,
|
PackagingFeeMode = fee.PackagingFeeMode,
|
||||||
|
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
|
||||||
PackagingFeeBreakdown = breakdown,
|
PackagingFeeBreakdown = breakdown,
|
||||||
TotalFee = totalFee,
|
TotalFee = totalFee,
|
||||||
TotalAmount = totalAmount
|
TotalAmount = totalAmount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static decimal ResolveTieredFee(decimal orderAmount, IReadOnlyList<StoreFeeTierDto> tiers)
|
||||||
|
{
|
||||||
|
foreach (var tier in tiers)
|
||||||
|
{
|
||||||
|
if (orderAmount < tier.MinPrice)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tier.MaxPrice.HasValue || orderAmount <= tier.MaxPrice.Value)
|
||||||
|
{
|
||||||
|
return tier.Fee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiers[^1].Fee;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(TakeoutAppDbContext))]
|
||||||
|
[Migration("20260201090000_AddStoreFeeTieredPackaging")]
|
||||||
|
public partial class AddStoreFeeTieredPackaging : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OrderPackagingFeeMode",
|
||||||
|
table: "store_fees",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: "订单打包费规则(按订单收费时生效)。");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PackagingFeeTiersJson",
|
||||||
|
table: "store_fees",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
comment: "阶梯打包费配置(JSON)。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OrderPackagingFeeMode",
|
||||||
|
table: "store_fees");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PackagingFeeTiersJson",
|
||||||
|
table: "store_fees");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5434,6 +5434,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasComment("打包费模式。");
|
.HasComment("打包费模式。");
|
||||||
|
|
||||||
|
b.Property<int>("OrderPackagingFeeMode")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasComment("订单打包费规则(按订单收费时生效)。");
|
||||||
|
|
||||||
|
b.Property<string>("PackagingFeeTiersJson")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasComment("阶梯打包费配置(JSON)。");
|
||||||
|
|
||||||
b.Property<long>("StoreId")
|
b.Property<long>("StoreId")
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasComment("门店标识。");
|
.HasComment("门店标识。");
|
||||||
|
|||||||
Reference in New Issue
Block a user