From 725f89ae246b91e40ab5ab8a3bebc52db2b8274d Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 26 Jan 2026 09:26:49 +0800 Subject: [PATCH] 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. --- .../Stores/Commands/UpdateStoreFeeCommand.cs | 10 + .../Dto/StoreFeeCalculationResultDto.cs | 5 + .../App/Stores/Dto/StoreFeeDto.cs | 10 + .../App/Stores/Dto/StoreFeeTierDto.cs | 22 ++ .../Handlers/CalculateStoreFeeQueryHandler.cs | 1 + .../Handlers/CreateStoreCommandHandler.cs | 1 + .../Handlers/GetStoreFeeQueryHandler.cs | 1 + .../Handlers/UpdateStoreFeeCommandHandler.cs | 19 +- .../App/Stores/StoreFeeTierHelper.cs | 65 ++++ .../App/Stores/StoreMapping.cs | 2 + .../UpdateStoreFeeCommandValidator.cs | 54 ++- .../Stores/Entities/StoreFee.cs | 10 + .../Stores/Enums/OrderPackagingFeeMode.cs | 17 + .../App/Persistence/TakeoutAppDbContext.cs | 2 + .../App/Services/DeliveryZoneService.cs | 350 +++++++++++++++--- .../Services/StoreFeeCalculationService.cs | 35 +- ...260201090000_AddStoreFeeTieredPackaging.cs | 45 +++ .../TakeoutAppDbContextModelSnapshot.cs | 8 + 18 files changed, 604 insertions(+), 53 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeTierDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Stores/StoreFeeTierHelper.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Stores/Enums/OrderPackagingFeeMode.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260201090000_AddStoreFeeTieredPackaging.cs diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs index d569dcd..8a64005 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreFeeCommand.cs @@ -29,11 +29,21 @@ public sealed record UpdateStoreFeeCommand : IRequest /// public PackagingFeeMode PackagingFeeMode { get; init; } + /// + /// 订单打包费规则。 + /// + public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; } = OrderPackagingFeeMode.Fixed; + /// /// 固定打包费。 /// public decimal? FixedPackagingFee { get; init; } + /// + /// 阶梯打包费配置。 + /// + public IReadOnlyList PackagingFeeTiers { get; init; } = []; + /// /// 免配送费门槛。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationResultDto.cs index 4b8195b..6f766c2 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationResultDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeCalculationResultDto.cs @@ -42,6 +42,11 @@ public sealed record StoreFeeCalculationResultDto /// public PackagingFeeMode PackagingFeeMode { get; init; } + /// + /// 订单打包费规则。 + /// + public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; } + /// /// 打包费拆分明细。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs index ec68948..8a38810 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeDto.cs @@ -36,11 +36,21 @@ public sealed record StoreFeeDto /// public PackagingFeeMode PackagingFeeMode { get; init; } + /// + /// 订单打包费规则。 + /// + public OrderPackagingFeeMode OrderPackagingFeeMode { get; init; } + /// /// 固定打包费。 /// public decimal FixedPackagingFee { get; init; } + /// + /// 阶梯打包费配置。 + /// + public IReadOnlyList PackagingFeeTiers { get; init; } = []; + /// /// 免配送费门槛。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeTierDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeTierDto.cs new file mode 100644 index 0000000..502fa17 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreFeeTierDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 打包费阶梯配置 DTO。 +/// +public sealed record StoreFeeTierDto +{ + /// + /// 阶梯起始金额。 + /// + public decimal MinPrice { get; init; } + + /// + /// 阶梯截止金额(为空表示无限)。 + /// + public decimal? MaxPrice { get; init; } + + /// + /// 阶梯打包费。 + /// + public decimal Fee { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CalculateStoreFeeQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CalculateStoreFeeQueryHandler.cs index aa33124..cd72893 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CalculateStoreFeeQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CalculateStoreFeeQueryHandler.cs @@ -42,6 +42,7 @@ public sealed class CalculateStoreFeeQueryHandler( MinimumOrderAmount = 0m, BaseDeliveryFee = 0m, PackagingFeeMode = PackagingFeeMode.Fixed, + OrderPackagingFeeMode = OrderPackagingFeeMode.Fixed, FixedPackagingFee = 0m }; diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs index 7815e87..5acd666 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs @@ -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); diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreFeeQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreFeeQueryHandler.cs index f73bcc3..824eba2 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreFeeQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreFeeQueryHandler.cs @@ -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); diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs index 24efa85..d734c71 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreFeeCommandHandler.cs @@ -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. (空行后) 保存并返回 diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreFeeTierHelper.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreFeeTierHelper.cs new file mode 100644 index 0000000..c13cc64 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreFeeTierHelper.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores; + +/// +/// 打包费阶梯 JSON 辅助方法。 +/// +public static class StoreFeeTierHelper +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static IReadOnlyList Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + public static string? Serialize(IReadOnlyList tiers) + { + if (tiers.Count == 0) + { + return null; + } + + return JsonSerializer.Serialize(tiers, JsonOptions); + } + + public static IReadOnlyList Normalize(IReadOnlyList tiers) + { + if (tiers.Count == 0) + { + return []; + } + + var normalized = new List(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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs index 29b77df..3dc8357 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs @@ -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 diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs index 1f93073..2b145bd 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreFeeCommandValidator.cs @@ -21,7 +21,7 @@ public sealed class UpdateStoreFeeCommandValidator : AbstractValidator 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 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; + } + }); } } diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs index 974a114..4856e28 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreFee.cs @@ -28,11 +28,21 @@ public sealed class StoreFee : MultiTenantEntityBase /// public PackagingFeeMode PackagingFeeMode { get; set; } = PackagingFeeMode.Fixed; + /// + /// 订单打包费规则(按订单收费时生效)。 + /// + public OrderPackagingFeeMode OrderPackagingFeeMode { get; set; } = OrderPackagingFeeMode.Fixed; + /// /// 固定打包费(总计模式有效)。 /// public decimal FixedPackagingFee { get; set; } = 0m; + /// + /// 阶梯打包费配置(JSON)。 + /// + public string? PackagingFeeTiersJson { get; set; } + /// /// 免配送费门槛。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/OrderPackagingFeeMode.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/OrderPackagingFeeMode.cs new file mode 100644 index 0000000..a50fe27 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/OrderPackagingFeeMode.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 订单打包费计算规则。 +/// +public enum OrderPackagingFeeMode +{ + /// + /// 一口价。 + /// + Fixed = 0, + + /// + /// 阶梯价。 + /// + Tiered = 1 +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 1e9722c..54ca514 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -612,7 +612,9 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.MinimumOrderAmount).HasPrecision(10, 2); builder.Property(x => x.BaseDeliveryFee).HasPrecision(10, 2); builder.Property(x => x.PackagingFeeMode).HasConversion(); + builder.Property(x => x.OrderPackagingFeeMode).HasConversion(); builder.Property(x => x.FixedPackagingFee).HasPrecision(10, 2); + builder.Property(x => x.PackagingFeeTiersJson).HasColumnType("text"); builder.Property(x => x.FreeDeliveryThreshold).HasPrecision(10, 2); builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); builder.HasIndex(x => x.TenantId); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/DeliveryZoneService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/DeliveryZoneService.cs index 4c3f96e..6a2c982 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/DeliveryZoneService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/DeliveryZoneService.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Linq; using System.Text.Json; using TakeoutSaaS.Application.App.Stores.Dto; using TakeoutSaaS.Application.App.Stores.Services; @@ -10,6 +12,8 @@ namespace TakeoutSaaS.Infrastructure.App.Services; /// public sealed class DeliveryZoneService : IDeliveryZoneService { + private const double CoordinateTolerance = 1e-6; + /// public StoreDeliveryCheckResultDto CheckPointInZones( IReadOnlyList zones, @@ -24,12 +28,16 @@ public sealed class DeliveryZoneService : IDeliveryZoneService // 2. (空行后) 逐个检测多边形命中 foreach (var zone in zones) { - if (!TryReadPolygon(zone.PolygonGeoJson, out var polygon)) + if (!TryReadPolygons(zone.PolygonGeoJson, out var polygons)) { continue; } - if (IsPointInPolygon(polygon, longitude, latitude)) + foreach (var polygon in polygons) { + if (!IsPointInPolygon(polygon, longitude, latitude)) + { + continue; + } return new StoreDeliveryCheckResultDto { InRange = true, @@ -42,9 +50,9 @@ public sealed class DeliveryZoneService : IDeliveryZoneService return new StoreDeliveryCheckResultDto { InRange = false }; } - private static bool TryReadPolygon(string geoJson, out List polygon) + private static bool TryReadPolygons(string geoJson, out List polygons) { - polygon = []; + polygons = []; if (string.IsNullOrWhiteSpace(geoJson)) { return false; @@ -52,41 +60,11 @@ public sealed class DeliveryZoneService : IDeliveryZoneService try { using var document = JsonDocument.Parse(geoJson); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object) + if (!TryReadPolygons(document.RootElement, polygons)) { return false; } - if (!root.TryGetProperty("coordinates", out var coordinatesElement) || coordinatesElement.ValueKind != JsonValueKind.Array) - { - 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; + return polygons.Count > 0; } catch (JsonException) { @@ -94,28 +72,304 @@ public sealed class DeliveryZoneService : IDeliveryZoneService } } - private static bool IsPointInPolygon(IReadOnlyList polygon, double x, double y) + private static bool TryReadPolygons(JsonElement root, ICollection polygons) { - var inside = false; - for (var i = 0; i < polygon.Count; i++) + if (root.ValueKind == JsonValueKind.String) { - var j = i == 0 ? polygon.Count - 1 : i - 1; - var xi = polygon[i].Longitude; - var yi = polygon[i].Latitude; - var xj = polygon[j].Longitude; - var yj = polygon[j].Latitude; - var intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi + double.Epsilon) + xi); - if (intersect) + var inner = root.GetString(); + if (string.IsNullOrWhiteSpace(inner)) + { + return false; + } + using var innerDocument = JsonDocument.Parse(inner); + 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> 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 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 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; } } + return inside; } + private static bool IsPointOnBoundary(IReadOnlyList 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) - => Math.Abs(first.Longitude - second.Longitude) <= 1e-6 - && Math.Abs(first.Latitude - second.Latitude) <= 1e-6; + => Math.Abs(first.Longitude - second.Longitude) <= CoordinateTolerance + && Math.Abs(first.Latitude - second.Latitude) <= CoordinateTolerance; private readonly record struct Point(double Longitude, double Latitude); + + private sealed record Polygon(IReadOnlyList Outer, IReadOnlyList> Holes); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreFeeCalculationService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreFeeCalculationService.cs index 5c4d4ed..1873ef0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreFeeCalculationService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreFeeCalculationService.cs @@ -1,4 +1,5 @@ using System.Globalization; +using TakeoutSaaS.Application.App.Stores; using TakeoutSaaS.Application.App.Stores.Dto; using TakeoutSaaS.Application.App.Stores.Services; using TakeoutSaaS.Domain.Stores.Entities; @@ -31,6 +32,7 @@ public sealed class StoreFeeCalculationService : IStoreFeeCalculationService DeliveryFee = 0m, PackagingFee = 0m, PackagingFeeMode = fee.PackagingFeeMode, + OrderPackagingFeeMode = fee.OrderPackagingFeeMode, TotalFee = 0m, TotalAmount = request.OrderAmount, Message = message @@ -49,7 +51,19 @@ public sealed class StoreFeeCalculationService : IStoreFeeCalculationService IReadOnlyList? breakdown = null; 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 { @@ -84,9 +98,28 @@ public sealed class StoreFeeCalculationService : IStoreFeeCalculationService DeliveryFee = deliveryFee, PackagingFee = packagingFee, PackagingFeeMode = fee.PackagingFeeMode, + OrderPackagingFeeMode = fee.OrderPackagingFeeMode, PackagingFeeBreakdown = breakdown, TotalFee = totalFee, TotalAmount = totalAmount }; } + + private static decimal ResolveTieredFee(decimal orderAmount, IReadOnlyList 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; + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260201090000_AddStoreFeeTieredPackaging.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260201090000_AddStoreFeeTieredPackaging.cs new file mode 100644 index 0000000..26e6fb9 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20260201090000_AddStoreFeeTieredPackaging.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using TakeoutSaaS.Infrastructure.App.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + [DbContext(typeof(TakeoutAppDbContext))] + [Migration("20260201090000_AddStoreFeeTieredPackaging")] + public partial class AddStoreFeeTieredPackaging : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OrderPackagingFeeMode", + table: "store_fees", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "订单打包费规则(按订单收费时生效)。"); + + migrationBuilder.AddColumn( + name: "PackagingFeeTiersJson", + table: "store_fees", + type: "text", + nullable: true, + comment: "阶梯打包费配置(JSON)。"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OrderPackagingFeeMode", + table: "store_fees"); + + migrationBuilder.DropColumn( + name: "PackagingFeeTiersJson", + table: "store_fees"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs index 58d9b17..9798c54 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs @@ -5434,6 +5434,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("integer") .HasComment("打包费模式。"); + b.Property("OrderPackagingFeeMode") + .HasColumnType("integer") + .HasComment("订单打包费规则(按订单收费时生效)。"); + + b.Property("PackagingFeeTiersJson") + .HasColumnType("text") + .HasComment("阶梯打包费配置(JSON)。"); + b.Property("StoreId") .HasColumnType("bigint") .HasComment("门店标识。");