fix: 收紧分区 range、进位相接与仅国家估算口径
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
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:
parent
c91d8ca0e6
commit
b748aa4000
@ -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
|
||||
|
||||
@ -66,8 +66,9 @@ internal static class ParcelBillingCalculator
|
||||
public static decimal ApplyRounding(decimal rawWeight, IReadOnlyList<WeightRoundingInfo> 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查价仅填国家时:若快照已配置该目的国分区(即使规则带邮编区间),
|
||||
/// 则返回卖价明细中的分区键供估算报价;未配置该国家时返回空(不跨国家兜底)。
|
||||
/// 查价仅填国家时:只使用该国分区规则对应的 zone 键(及与这些键重合的卖价明细分区)。
|
||||
/// 未配置该国家时返回空;不把别国明细分区拿来估算。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> 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<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)
|
||||
@ -2170,7 +2190,7 @@ internal static class ParcelBillingCalculator
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
throw new InvalidOperationException($"计重规则条件无效:{condition}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/// <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) =>
|
||||
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("起始邮编不能大于截止邮编");
|
||||
}
|
||||
|
||||
75
src/Oms.Application/Shared/WeightRoundingRangeGuard.cs
Normal file
75
src/Oms.Application/Shared/WeightRoundingRangeGuard.cs
Normal 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}] 之间存在缺口");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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<InvalidOperationException>().WithMessage("*计重进位区间未匹配*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 应收(卖价)
|
||||
|
||||
@ -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]
|
||||
|
||||
97
src/Oms.Tests/WeightRoundingRangeGuardTests.cs
Normal file
97
src/Oms.Tests/WeightRoundingRangeGuardTests.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user