diff --git a/src/Oms.Application/Services/Channels/WeightRoundingService.cs b/src/Oms.Application/Services/Channels/WeightRoundingService.cs index c919d5b..a24f4a7 100644 --- a/src/Oms.Application/Services/Channels/WeightRoundingService.cs +++ b/src/Oms.Application/Services/Channels/WeightRoundingService.cs @@ -113,6 +113,7 @@ public class WeightRoundingService : ApplicationService public async Task AddInfoAsync(WeightRoundingInfoSaveInput input) { await GetEntityOrThrowAsync(input.RoundingId); + await ValidateInfoRangeAsync(input); await WeightRoundingRepository.InsertInfoAsync(new WeightRoundingInfo { RoundingId = input.RoundingId, @@ -134,6 +135,7 @@ public class WeightRoundingService : ApplicationService { var entity = await GetInfoOrThrowAsync(input.Id); await GetEntityOrThrowAsync(input.RoundingId); + await ValidateInfoRangeAsync(input, entity.Id); entity.RoundingId = input.RoundingId; entity.StartValue = input.StartValue; entity.EndValue = input.EndValue; @@ -202,6 +204,16 @@ public class WeightRoundingService : ApplicationService return entity; } + private async Task ValidateInfoRangeAsync(WeightRoundingInfoSaveInput input, int? excludeId = null) + { + var existed = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(input.RoundingId); + WeightRoundingRangeGuard.EnsureContiguous( + input.StartValue, + input.EndValue, + existed, + excludeId ?? input.Id); + } + private static WeightRoundingOutput MapOutput(WeightRounding entity) { return new WeightRoundingOutput diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 0858b86..3b781c8 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -66,8 +66,9 @@ internal static class ParcelBillingCalculator public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList ranges) { var hit = ranges - .OrderBy(x => x.Id) - .FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue); + .OrderBy(x => x.StartValue) + .ThenBy(x => x.Id) + .FirstOrDefault(x => WeightInLeftOpenRightClosedInterval(rawWeight, x.StartValue, x.EndValue)); if (hit == null) { throw new InvalidOperationException("计重进位区间未匹配"); @@ -129,8 +130,8 @@ internal static class ParcelBillingCalculator } /// - /// 查价仅填国家时:若快照已配置该目的国分区(即使规则带邮编区间), - /// 则返回卖价明细中的分区键供估算报价;未配置该国家时返回空(不跨国家兜底)。 + /// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。 + /// 未配置该国家时返回空;不把别国明细分区拿来估算。 /// public static IReadOnlyList CollectCountryEstimateZoneKeys( SalesProductPricePublishSnapshotDto? priceSnapshot, @@ -143,15 +144,34 @@ internal static class ParcelBillingCalculator return []; } - var countryConfigured = priceSnapshot.PriceAreaInfos.Any(rule => - string.IsNullOrWhiteSpace(rule.CountryCode) - || string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)); - if (!countryConfigured) + var countryRules = priceSnapshot.PriceAreaInfos + .Where(rule => string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (countryRules.Count == 0) { return []; } - return CollectDistinctDetailZoneKeys(priceSnapshot); + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var rule in countryRules) + { + foreach (var key in BuildPriceZoneKeys(rule, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId)) + { + if (!string.IsNullOrWhiteSpace(key)) + { + allowed.Add(key.Trim()); + } + } + } + + var detailZones = CollectDistinctDetailZoneKeys(priceSnapshot); + var mapped = detailZones.Where(zone => allowed.Contains(zone)).ToList(); + if (mapped.Count > 0) + { + return mapped; + } + + return allowed.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); } public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area) @@ -2170,7 +2190,7 @@ internal static class ParcelBillingCalculator } catch { - return false; + throw new InvalidOperationException($"计重规则条件无效:{condition}"); } } diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs index 895f8c3..c83a0c1 100644 --- a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs +++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs @@ -52,6 +52,11 @@ internal sealed class PriceCheckInputProfile missing.Add(PriceCheckMissingFieldCodes.PostalCode); } + if (!hasPostal && !hasCity) + { + missing.Add(PriceCheckMissingFieldCodes.City); + } + if (!hasProvince) { missing.Add(PriceCheckMissingFieldCodes.Province); diff --git a/src/Oms.Application/Shared/GeoPartitionMatcher.cs b/src/Oms.Application/Shared/GeoPartitionMatcher.cs index 1aaae50..4eab0fd 100644 --- a/src/Oms.Application/Shared/GeoPartitionMatcher.cs +++ b/src/Oms.Application/Shared/GeoPartitionMatcher.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.RegularExpressions; using Oms.Application.Dtos.Orders; using Oms.Domain.ChannelsAggregate.Entities; @@ -65,6 +66,79 @@ public static class GeoPartitionMatcher return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant(); } + /// + /// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。 + /// + public static int CompareNormalizedZip(string? left, string? right) + { + var a = left ?? string.Empty; + var b = right ?? string.Empty; + if (a.Length == 0 && b.Length == 0) + { + return 0; + } + + if (IsDigits(a) && IsDigits(b)) + { + var width = Math.Max(a.Length, b.Length); + return string.CompareOrdinal(a.PadLeft(width, '0'), b.PadLeft(width, '0')); + } + + if (TrySplitLetterPrefixNumber(a, out var prefixA, out var numberA, out var restA) + && TrySplitLetterPrefixNumber(b, out var prefixB, out var numberB, out var restB) + && string.Equals(prefixA, prefixB, StringComparison.Ordinal)) + { + var numberCmp = numberA.CompareTo(numberB); + return numberCmp != 0 ? numberCmp : string.CompareOrdinal(restA, restB); + } + + return string.CompareOrdinal(a, b); + } + + private static bool IsDigits(string value) => + value.Length > 0 && value.All(char.IsAsciiDigit); + + private static bool TrySplitLetterPrefixNumber( + string zip, + out string prefix, + out int number, + out string rest) + { + prefix = string.Empty; + number = 0; + rest = string.Empty; + if (zip.Length == 0 || !char.IsAsciiLetter(zip[0])) + { + return false; + } + + var i = 0; + while (i < zip.Length && char.IsAsciiLetter(zip[i])) + { + i++; + } + + if (i >= zip.Length || !char.IsAsciiDigit(zip[i])) + { + return false; + } + + prefix = zip[..i]; + var digitStart = i; + while (i < zip.Length && char.IsAsciiDigit(zip[i])) + { + i++; + } + + if (!int.TryParse(zip[digitStart..i], NumberStyles.None, CultureInfo.InvariantCulture, out number)) + { + return false; + } + + rest = zip[i..]; + return true; + } + public static string NormalizeRegion(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); @@ -116,8 +190,8 @@ public static class GeoPartitionMatcher } else if (rule.MatchMode == ModeRange) { - hit = zipStd.CompareTo(rule.ZipStartStd) >= 0 - && zipStd.CompareTo(rule.ZipEndStd) <= 0; + hit = CompareNormalizedZip(zipStd, rule.ZipStartStd) >= 0 + && CompareNormalizedZip(zipStd, rule.ZipEndStd) <= 0; } if (hit) @@ -565,7 +639,7 @@ public static class GeoPartitionMatcher throw new InvalidOperationException("邮编区间必须填写截止邮编"); } - if (zipLeft.CompareTo(zipRight) > 0) + if (CompareNormalizedZip(zipLeft, zipRight) > 0) { throw new InvalidOperationException("起始邮编不能大于截止邮编"); } diff --git a/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs b/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs new file mode 100644 index 0000000..f2c2c84 --- /dev/null +++ b/src/Oms.Application/Shared/WeightRoundingRangeGuard.cs @@ -0,0 +1,75 @@ +using Fake.Application; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// 计重进位区间校验:同一进位制下左开右闭 (start, end],端点相接不算重叠,排序后不允许空洞。 +/// +public static class WeightRoundingRangeGuard +{ + public static void EnsureValidRange(decimal startValue, decimal endValue) + { + if (startValue >= endValue) + { + throw new BusinessException("起始值须小于截止值"); + } + } + + /// + /// (a, b] 与 (c, d] 是否重叠。 + /// + public static bool Overlaps(decimal startA, decimal endA, decimal startB, decimal endB) => + startA < endB && startB < endA; + + public static void EnsureNoOverlap( + decimal startValue, + decimal endValue, + IEnumerable existing, + int? excludeId = null) + { + EnsureValidRange(startValue, endValue); + foreach (var item in existing) + { + if (excludeId is > 0 && item.Id == excludeId.Value) + { + continue; + } + + if (Overlaps(startValue, endValue, item.StartValue, item.EndValue)) + { + throw new BusinessException( + $"起始区间 ({startValue}, {endValue}] 与已有区间 ({item.StartValue}, {item.EndValue}] 重叠"); + } + } + } + + public static void EnsureContiguous( + decimal startValue, + decimal endValue, + IEnumerable existing, + int? excludeId = null) + { + EnsureNoOverlap(startValue, endValue, existing, excludeId); + var merged = existing + .Where(item => excludeId is not > 0 || item.Id != excludeId.Value) + .Select(item => (Start: item.StartValue, End: item.EndValue)) + .Append((Start: startValue, End: endValue)) + .OrderBy(x => x.Start) + .ThenBy(x => x.End) + .ToList(); + + for (var i = 0; i < merged.Count - 1; i++) + { + var currentEnd = merged[i].End; + var nextStart = merged[i + 1].Start; + if (currentEnd == nextStart) + { + continue; + } + + throw new BusinessException( + $"起始区间必须首尾相接(左开右闭):({merged[i].Start}, {currentEnd}] 与 ({nextStart}, {merged[i + 1].End}] 之间存在缺口"); + } + } +} diff --git a/src/Oms.Tests/GeoPartitionMatcherTests.cs b/src/Oms.Tests/GeoPartitionMatcherTests.cs index 98d8d3a..e2a1afc 100644 --- a/src/Oms.Tests/GeoPartitionMatcherTests.cs +++ b/src/Oms.Tests/GeoPartitionMatcherTests.cs @@ -59,6 +59,38 @@ public class GeoPartitionMatcherTests GeoPartitionMatcher.IsHit(rules, "US", null, null, "90200").Should().BeFalse(); } + [Fact] + public void ZipRange_NumericDifferentLength_ShouldCompareAsNumbers() + { + var rules = CompileEntities(Range("US", "9", "10")); + + GeoPartitionMatcher.IsHit(rules, "US", null, null, "9").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "10").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "8").Should().BeFalse(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "11").Should().BeFalse(); + } + + [Fact] + public void ZipRange_LeadingZeros_ShouldKeepZipWidth() + { + var rules = CompileEntities(Range("US", "02100", "02200")); + + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02115").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02100").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02200").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "US", null, null, "02099").Should().BeFalse(); + } + + [Fact] + public void ZipRange_AlphaNumericPrefix_ShouldCompareNumericTail() + { + var rules = CompileEntities(Range("GB", "YO0", "YO9")); + + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO0").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO9").Should().BeTrue(); + GeoPartitionMatcher.IsHit(rules, "GB", null, null, "YO10").Should().BeFalse(); + } + [Fact] public void CountryFallback_ShouldHitWhenZipMisses() { diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs index 91b621d..65ef824 100644 --- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs +++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs @@ -48,6 +48,29 @@ public class ParcelBillingCalculatorTests rounded.Should().Be(1.5M); } + [Fact] + public void ApplyRounding_LeftOpenRightClosed_BoundaryGoesToLeftRange() + { + var ranges = new[] + { + ParcelBillingTestData.RoundingRange(0, 10, "0.5"), + ParcelBillingTestData.RoundingRange(10, 30, "1"), + }; + + ParcelBillingCalculator.ApplyRounding(10M, ranges).Should().Be(10M); + ParcelBillingCalculator.ApplyRounding(10.01M, ranges).Should().Be(11M); + } + + [Fact] + public void ApplyRounding_LeftOpenRightClosed_StartIsExclusive() + { + var ranges = new[] { ParcelBillingTestData.RoundingRange(0, 30, "0.5") }; + + var act = () => ParcelBillingCalculator.ApplyRounding(0M, ranges); + + act.Should().Throw().WithMessage("*计重进位区间未匹配*"); + } + #endregion #region 应收(卖价) diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs index 0dd3f7d..a22ed8f 100644 --- a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs +++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs @@ -308,23 +308,38 @@ public class PriceCheckSnapshotVerificationTests public void CountryEstimate_WhenCountryConfiguredWithPostcodeRules_ShouldReturnDetailZones() { var snapshot = BuildUserSnapshot(); + snapshot.Prices.Add(PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput + { + Id = 9, + Status = 2, + CostItem = "freight", + Details = [Detail(90, 9, "total", "99", "0,2", 1)] + })); snapshot.PriceAreaInfos = [ new KpiAreaSnapshotRuleDto { - Id = 11, + Id = 3, CountryCode = "US", PostcodeRange = "(90000,99999]" + }, + new KpiAreaSnapshotRuleDto + { + Id = 99, + CountryCode = "DE", + PostcodeRange = "(00000,99999]" } ]; - // 精确匹配(无邮编)应失败 ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(snapshot, "US", null, null, null) .Should().BeNull(); - // 国家估算应返回卖价明细分区键 ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "US") - .Should().Contain(["3"]); + .Should().Contain("3") + .And.NotContain("99"); + ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "DE") + .Should().Contain("99") + .And.NotContain("3"); } [Fact] diff --git a/src/Oms.Tests/WeightRoundingRangeGuardTests.cs b/src/Oms.Tests/WeightRoundingRangeGuardTests.cs new file mode 100644 index 0000000..24f0236 --- /dev/null +++ b/src/Oms.Tests/WeightRoundingRangeGuardTests.cs @@ -0,0 +1,97 @@ +using Fake.Application; +using FluentAssertions; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Tests; + +public class WeightRoundingRangeGuardTests +{ + [Fact] + public void AdjacentLeftOpenRightClosed_ShouldNotOverlap() + { + var existing = new[] { Range(1, 0, 30) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(30, 60, existing); + + act.Should().NotThrow(); + } + + [Fact] + public void ContainedRange_ShouldOverlap() + { + var existing = new[] { Range(1, 0, 30) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(5, 10, existing); + + act.Should().Throw().WithMessage("*重叠*"); + } + + [Fact] + public void CrossingRange_ShouldOverlap() + { + var existing = new[] { Range(1, 0, 30) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(20, 40, existing); + + act.Should().Throw(); + } + + [Fact] + public void UpdateSameRow_ShouldIgnoreSelf() + { + var existing = new[] { Range(8, 0, 30), Range(9, 30, 60) }; + + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(0, 30, existing, excludeId: 8); + + act.Should().NotThrow(); + } + + [Fact] + public void StartNotLessThanEnd_ShouldFail() + { + var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(30, 30, []); + + act.Should().Throw().WithMessage("*起始值须小于截止值*"); + } + + [Fact] + public void HoleBetweenRanges_ShouldFail() + { + var existing = new[] { Range(1, 0, 10) }; + + var act = () => WeightRoundingRangeGuard.EnsureContiguous(20, 30, existing); + + act.Should().Throw().WithMessage("*缺口*"); + } + + [Fact] + public void AdjacentRanges_ShouldBeContiguous() + { + var existing = new[] { Range(1, 0, 10) }; + + var act = () => WeightRoundingRangeGuard.EnsureContiguous(10, 25, existing); + + act.Should().NotThrow(); + } + + [Fact] + public void UpdateSameRow_Contiguous_ShouldIgnoreSelf() + { + var existing = new[] { Range(8, 0, 30), Range(9, 30, 60) }; + + var act = () => WeightRoundingRangeGuard.EnsureContiguous(0, 30, existing, excludeId: 8); + + act.Should().NotThrow(); + } + + private static WeightRoundingInfo Range(int id, decimal start, decimal end) => + new() + { + Id = id, + RoundingId = 1, + StartValue = start, + EndValue = end, + Public = "0.5" + }; +}