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("门店标识。");