fix: 收紧分区 range、进位相接与仅国家估算口径
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s

进位区间改为左开右闭且禁止空洞,邮编 range 按数字/字母数字比较,查价仅国家时不再串用别国 zone。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-05 17:35:18 +08:00
parent c91d8ca0e6
commit b748aa4000
9 changed files with 370 additions and 17 deletions

View File

@ -113,6 +113,7 @@ public class WeightRoundingService : ApplicationService
public async Task AddInfoAsync(WeightRoundingInfoSaveInput input) public async Task AddInfoAsync(WeightRoundingInfoSaveInput input)
{ {
await GetEntityOrThrowAsync(input.RoundingId); await GetEntityOrThrowAsync(input.RoundingId);
await ValidateInfoRangeAsync(input);
await WeightRoundingRepository.InsertInfoAsync(new WeightRoundingInfo await WeightRoundingRepository.InsertInfoAsync(new WeightRoundingInfo
{ {
RoundingId = input.RoundingId, RoundingId = input.RoundingId,
@ -134,6 +135,7 @@ public class WeightRoundingService : ApplicationService
{ {
var entity = await GetInfoOrThrowAsync(input.Id); var entity = await GetInfoOrThrowAsync(input.Id);
await GetEntityOrThrowAsync(input.RoundingId); await GetEntityOrThrowAsync(input.RoundingId);
await ValidateInfoRangeAsync(input, entity.Id);
entity.RoundingId = input.RoundingId; entity.RoundingId = input.RoundingId;
entity.StartValue = input.StartValue; entity.StartValue = input.StartValue;
entity.EndValue = input.EndValue; entity.EndValue = input.EndValue;
@ -202,6 +204,16 @@ public class WeightRoundingService : ApplicationService
return entity; 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) private static WeightRoundingOutput MapOutput(WeightRounding entity)
{ {
return new WeightRoundingOutput return new WeightRoundingOutput

View File

@ -66,8 +66,9 @@ internal static class ParcelBillingCalculator
public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList<WeightRoundingInfo> ranges) public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList<WeightRoundingInfo> ranges)
{ {
var hit = ranges var hit = ranges
.OrderBy(x => x.Id) .OrderBy(x => x.StartValue)
.FirstOrDefault(x => rawWeight >= x.StartValue && rawWeight <= x.EndValue); .ThenBy(x => x.Id)
.FirstOrDefault(x => WeightInLeftOpenRightClosedInterval(rawWeight, x.StartValue, x.EndValue));
if (hit == null) if (hit == null)
{ {
throw new InvalidOperationException("计重进位区间未匹配"); throw new InvalidOperationException("计重进位区间未匹配");
@ -129,8 +130,8 @@ internal static class ParcelBillingCalculator
} }
/// <summary> /// <summary>
/// 查价仅填国家时:若快照已配置该目的国分区(即使规则带邮编区间), /// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。
/// 则返回卖价明细中的分区键供估算报价;未配置该国家时返回空(不跨国家兜底)。 /// 未配置该国家时返回空;不把别国明细分区拿来估算。
/// </summary> /// </summary>
public static IReadOnlyList<string> CollectCountryEstimateZoneKeys( public static IReadOnlyList<string> CollectCountryEstimateZoneKeys(
SalesProductPricePublishSnapshotDto? priceSnapshot, SalesProductPricePublishSnapshotDto? priceSnapshot,
@ -143,15 +144,34 @@ internal static class ParcelBillingCalculator
return []; return [];
} }
var countryConfigured = priceSnapshot.PriceAreaInfos.Any(rule => var countryRules = priceSnapshot.PriceAreaInfos
string.IsNullOrWhiteSpace(rule.CountryCode) .Where(rule => string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase))
|| string.Equals(rule.CountryCode, countryCode, StringComparison.OrdinalIgnoreCase)); .ToList();
if (!countryConfigured) if (countryRules.Count == 0)
{ {
return []; return [];
} }
return CollectDistinctDetailZoneKeys(priceSnapshot); var allowed = new HashSet<string>(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) public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
@ -2170,7 +2190,7 @@ internal static class ParcelBillingCalculator
} }
catch catch
{ {
return false; throw new InvalidOperationException($"计重规则条件无效:{condition}");
} }
} }

View File

@ -52,6 +52,11 @@ internal sealed class PriceCheckInputProfile
missing.Add(PriceCheckMissingFieldCodes.PostalCode); missing.Add(PriceCheckMissingFieldCodes.PostalCode);
} }
if (!hasPostal && !hasCity)
{
missing.Add(PriceCheckMissingFieldCodes.City);
}
if (!hasProvince) if (!hasProvince)
{ {
missing.Add(PriceCheckMissingFieldCodes.Province); missing.Add(PriceCheckMissingFieldCodes.Province);

View File

@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
@ -65,6 +66,79 @@ public static class GeoPartitionMatcher
return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant(); return chars.Length == 0 ? string.Empty : new string(chars).ToUpperInvariant();
} }
/// <summary>
/// 规范化后的邮编比较:纯数字按等长补零;相同字母前缀+数字后缀按数值;其余按 Ordinal。
/// </summary>
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) => public static string NormalizeRegion(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
@ -116,8 +190,8 @@ public static class GeoPartitionMatcher
} }
else if (rule.MatchMode == ModeRange) else if (rule.MatchMode == ModeRange)
{ {
hit = zipStd.CompareTo(rule.ZipStartStd) >= 0 hit = CompareNormalizedZip(zipStd, rule.ZipStartStd) >= 0
&& zipStd.CompareTo(rule.ZipEndStd) <= 0; && CompareNormalizedZip(zipStd, rule.ZipEndStd) <= 0;
} }
if (hit) if (hit)
@ -565,7 +639,7 @@ public static class GeoPartitionMatcher
throw new InvalidOperationException("邮编区间必须填写截止邮编"); throw new InvalidOperationException("邮编区间必须填写截止邮编");
} }
if (zipLeft.CompareTo(zipRight) > 0) if (CompareNormalizedZip(zipLeft, zipRight) > 0)
{ {
throw new InvalidOperationException("起始邮编不能大于截止邮编"); throw new InvalidOperationException("起始邮编不能大于截止邮编");
} }

View File

@ -0,0 +1,75 @@
using Fake.Application;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Application.Shared;
/// <summary>
/// 计重进位区间校验:同一进位制下左开右闭 (start, end],端点相接不算重叠,排序后不允许空洞。
/// </summary>
public static class WeightRoundingRangeGuard
{
public static void EnsureValidRange(decimal startValue, decimal endValue)
{
if (startValue >= endValue)
{
throw new BusinessException("起始值须小于截止值");
}
}
/// <summary>
/// (a, b] 与 (c, d] 是否重叠。
/// </summary>
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<WeightRoundingInfo> 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<WeightRoundingInfo> 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}] 之间存在缺口");
}
}
}

View File

@ -59,6 +59,38 @@ public class GeoPartitionMatcherTests
GeoPartitionMatcher.IsHit(rules, "US", null, null, "90200").Should().BeFalse(); 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] [Fact]
public void CountryFallback_ShouldHitWhenZipMisses() public void CountryFallback_ShouldHitWhenZipMisses()
{ {

View File

@ -48,6 +48,29 @@ public class ParcelBillingCalculatorTests
rounded.Should().Be(1.5M); 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<InvalidOperationException>().WithMessage("*计重进位区间未匹配*");
}
#endregion #endregion
#region 应收(卖价) #region 应收(卖价)

View File

@ -308,23 +308,38 @@ public class PriceCheckSnapshotVerificationTests
public void CountryEstimate_WhenCountryConfiguredWithPostcodeRules_ShouldReturnDetailZones() public void CountryEstimate_WhenCountryConfiguredWithPostcodeRules_ShouldReturnDetailZones()
{ {
var snapshot = BuildUserSnapshot(); 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 = snapshot.PriceAreaInfos =
[ [
new KpiAreaSnapshotRuleDto new KpiAreaSnapshotRuleDto
{ {
Id = 11, Id = 3,
CountryCode = "US", CountryCode = "US",
PostcodeRange = "(90000,99999]" PostcodeRange = "(90000,99999]"
},
new KpiAreaSnapshotRuleDto
{
Id = 99,
CountryCode = "DE",
PostcodeRange = "(00000,99999]"
} }
]; ];
// 精确匹配(无邮编)应失败
ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(snapshot, "US", null, null, null) ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(snapshot, "US", null, null, null)
.Should().BeNull(); .Should().BeNull();
// 国家估算应返回卖价明细分区键
ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "US") ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "US")
.Should().Contain(["3"]); .Should().Contain("3")
.And.NotContain("99");
ParcelBillingCalculator.CollectCountryEstimateZoneKeys(snapshot, "DE")
.Should().Contain("99")
.And.NotContain("3");
} }
[Fact] [Fact]

View File

@ -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<BusinessException>().WithMessage("*重叠*");
}
[Fact]
public void CrossingRange_ShouldOverlap()
{
var existing = new[] { Range(1, 0, 30) };
var act = () => WeightRoundingRangeGuard.EnsureNoOverlap(20, 40, existing);
act.Should().Throw<BusinessException>();
}
[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<BusinessException>().WithMessage("*起始值须小于截止值*");
}
[Fact]
public void HoleBetweenRanges_ShouldFail()
{
var existing = new[] { Range(1, 0, 10) };
var act = () => WeightRoundingRangeGuard.EnsureContiguous(20, 30, existing);
act.Should().Throw<BusinessException>().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"
};
}