feat: 价格发布快照固化分区规则,查价优先从快照匹配不再查库

发布/LIVE 快照写入 PriceAreaId、PriceAreaInfos;US+06000 命中分区键 3 单测通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 11:20:33 +08:00
parent cd061aaaed
commit 3a65e8c58f
8 changed files with 252 additions and 14 deletions

View File

@ -12,6 +12,21 @@ public class SalesProductPricePublishSnapshotDto
/// </summary>
public int SalesProductId { get; set; }
/// <summary>
/// 价格分区 ID(发布时固化,查价/计费优先用快照内分区规则)。
/// </summary>
public int? PriceAreaId { get; set; }
/// <summary>
/// 价格分区名称。
/// </summary>
public string? PriceAreaName { get; set; }
/// <summary>
/// 价格分区明细(国家/邮编等),发布时从 t_kpi_area_info 固化。
/// </summary>
public List<KpiAreaInfoOutput> PriceAreaInfos { get; set; } = [];
/// <summary>
/// 价格项列表。
/// </summary>

View File

@ -1,4 +1,5 @@
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
@ -160,13 +161,20 @@ public partial class SalesProductService
private async Task CreatePublishRecordAsync(int salesProductId, string operatorType, string? description)
{
var product = await GetProductOrThrowAsync(salesProductId);
object snapshot = operatorType == PriceOperatorType
? new SalesProductPricePublishSnapshot
object snapshot;
if (operatorType == PriceOperatorType)
{
var priceSnapshot = new SalesProductPricePublishSnapshot
{
SalesProductId = salesProductId,
Prices = await GetPriceOutputsAsync(salesProductId)
}
: new SalesProductRulesPublishSnapshot
};
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository);
snapshot = priceSnapshot;
}
else
{
snapshot = new SalesProductRulesPublishSnapshot
{
Product = new SalesProductSaveInput
{
@ -189,6 +197,7 @@ public partial class SalesProductService
},
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
};
}
// Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。
await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord
@ -421,11 +430,8 @@ public partial class SalesProductService
};
}
private sealed class SalesProductPricePublishSnapshot
private sealed class SalesProductPricePublishSnapshot : SalesProductPricePublishSnapshotDto
{
public int SalesProductId { get; set; }
public List<SalesProductPriceListOutput> Prices { get; set; } = [];
}
private sealed class SalesProductRulesPublishSnapshot

View File

@ -20,6 +20,7 @@ public partial class SalesProductService : ApplicationService
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
/// <summary>
/// 查询销售产品列表。

View File

@ -89,6 +89,7 @@ public partial class OrderService
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
@ -317,7 +318,7 @@ public partial class OrderService
.GroupBy(x => x.PriceId)
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
return new SalesProductPricePublishSnapshotDto
var snapshot = new SalesProductPricePublishSnapshotDto
{
SalesProductId = product.Id,
Prices = prices.Select(price => new SalesProductPriceListOutput
@ -340,6 +341,8 @@ public partial class OrderService
: []
}).ToList()
};
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository);
return snapshot;
}
private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity)
@ -411,7 +414,8 @@ public partial class OrderService
private static int? ResolveEffectivePriceAreaId(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
SalesProductPricePublishSnapshotDto? priceSnapshot)
{
if (product.PriceAreaId is > 0)
{
@ -423,18 +427,34 @@ public partial class OrderService
return rulesSnapshot.Product.PriceAreaId;
}
if (priceSnapshot?.PriceAreaId is > 0)
{
return priceSnapshot.PriceAreaId;
}
return null;
}
private async Task<IReadOnlyList<string>?> ResolvePriceZoneKeysAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
SalesProductPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode)
{
var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot);
if (priceSnapshot?.PriceAreaInfos is { Count: > 0 })
{
return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
priceSnapshot,
countryCode,
province,
city,
zipCode);
}
var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot);
if (!priceAreaId.HasValue)
{
return null;

View File

@ -217,7 +217,7 @@ public partial class OrderService
return PriceCheckFailure(product, "weight", ex.Message);
}
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules);
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
if (!priceAreaId.HasValue)
{
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
@ -233,6 +233,7 @@ public partial class OrderService
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
context.CountryCode,
context.Province,
context.City,
@ -246,8 +247,8 @@ public partial class OrderService
return PriceCheckFailure(
product,
"zone",
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区规则(PriceAreaId={priceAreaId})," +
"无法解析与卖价一致的分区键,请检查产品 PriceAreaId 及 t_kpi_area_info 国家/邮编配置");
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," +
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致");
}
var chargeableWeight = forecast.ChargeableWeight ?? 0;

View File

@ -122,6 +122,40 @@ internal static class ParcelBillingCalculator
return true;
}
public static bool IsAreaMatched(
KpiAreaInfoOutput rule,
string countryCode,
string? province,
string? city,
string? zipCode) =>
IsAreaMatched(MapInfoOutputToEntity(rule), countryCode, province, city, zipCode);
/// <summary>
/// 从卖价快照内固化的分区规则解析分区键(不查库)。
/// </summary>
public static IReadOnlyList<string>? TryResolveZoneKeysFromPriceSnapshot(
SalesProductPricePublishSnapshotDto? priceSnapshot,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (priceSnapshot?.PriceAreaInfos == null || priceSnapshot.PriceAreaInfos.Count == 0)
{
return null;
}
var matched = priceSnapshot.PriceAreaInfos.FirstOrDefault(rule =>
IsAreaMatched(rule, countryCode, province, city, zipCode));
if (matched == null)
{
return null;
}
var keys = BuildPriceZoneKeys(matched, priceSnapshot.PriceAreaName, priceSnapshot.PriceAreaId);
return keys.Count > 0 ? keys : null;
}
public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
{
if (matched == null)
@ -187,6 +221,75 @@ internal static class ParcelBillingCalculator
return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
public static IReadOnlyList<string> BuildPriceZoneKeys(
KpiAreaInfoOutput matched,
string? areaName,
int? areaId)
{
var keys = new List<string>();
var name = ResolveZoneName(matched, areaName);
if (!string.IsNullOrWhiteSpace(name))
{
keys.Add(name);
}
keys.Add(matched.Id.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(matched.Province))
{
keys.Add(matched.Province);
}
if (!string.IsNullOrWhiteSpace(matched.CountryName))
{
keys.Add(matched.CountryName);
}
if (areaId is > 0)
{
keys.Add(areaId.Value.ToString(CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(areaName))
{
keys.Add(areaName);
}
return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
public static string? ResolveZoneName(KpiAreaInfoOutput matched, string? areaName)
{
if (!string.IsNullOrWhiteSpace(matched.Province))
{
return matched.Province;
}
if (!string.IsNullOrWhiteSpace(matched.City))
{
return matched.City;
}
if (!string.IsNullOrWhiteSpace(matched.CountryName))
{
return matched.CountryName;
}
return areaName;
}
private static KpiAreaInfo MapInfoOutputToEntity(KpiAreaInfoOutput rule) =>
new()
{
Id = rule.Id,
AreaId = rule.AreaId,
CountryCode = rule.CountryCode,
CountryName = rule.CountryName,
Province = rule.Province,
City = rule.City,
LeftPostcode = rule.LeftPostcode,
RightPostcode = rule.RightPostcode
};
public static List<OrderFee> CalculateQuoteReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,

View File

@ -0,0 +1,51 @@
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 将价格分区(t_kpi_area + t_kpi_area_info)固化进卖价发布快照。
/// </summary>
public static class PriceAreaSnapshotHelper
{
/// <summary>
/// 按产品 PriceAreaId 加载分区头与明细写入快照。
/// </summary>
public static async Task ApplyPriceAreaToSnapshotAsync(
SalesProductPricePublishSnapshotDto snapshot,
SalesProduct product,
IKpiAreaRepository kpiAreaRepository,
CancellationToken cancellationToken = default)
{
if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0)
{
snapshot.PriceAreaId = null;
snapshot.PriceAreaName = null;
snapshot.PriceAreaInfos = [];
return;
}
snapshot.PriceAreaId = product.PriceAreaId;
var area = await kpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value, cancellationToken);
snapshot.PriceAreaName = area?.Name;
var infos = await kpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value, cancellationToken);
snapshot.PriceAreaInfos = infos.Select(MapInfoOutput).ToList();
}
private static KpiAreaInfoOutput MapInfoOutput(KpiAreaInfo entity) =>
new()
{
Id = entity.Id,
AreaId = entity.AreaId,
CountryCode = entity.CountryCode,
CountryName = entity.CountryName,
Province = entity.Province,
City = entity.City,
LeftPostcode = entity.LeftPostcode,
RightPostcode = entity.RightPostcode,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}

View File

@ -142,6 +142,47 @@ public class PriceCheckSnapshotVerificationTests
fees.Sum(x => x.Amount).Should().Be(31M);
}
[Fact]
public void TryResolveZoneKeysFromPriceSnapshot_UsZip06000_ShouldMatchZone3()
{
var snapshot = new SalesProductPricePublishSnapshotDto
{
PriceAreaId = 3,
PriceAreaName = "美国分区",
PriceAreaInfos =
[
new KpiAreaInfoOutput
{
Id = 4,
AreaId = 3,
CountryCode = "US",
CountryName = "美国",
LeftPostcode = "50000",
RightPostcode = "90000"
},
new KpiAreaInfoOutput
{
Id = 3,
AreaId = 3,
CountryCode = "US",
CountryName = "美国",
LeftPostcode = "00000",
RightPostcode = "50000"
}
]
};
var keys = ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
snapshot,
"US",
null,
null,
"06000");
keys.Should().NotBeNull();
keys!.Should().Contain("3");
}
[Fact]
public void ExplainQuotePriceMiss_ZoneMismatch_ShouldNameEachPriceItem()
{