feat: 价格发布快照固化分区规则,查价优先从快照匹配不再查库
发布/LIVE 快照写入 PriceAreaId、PriceAreaInfos;US+06000 命中分区键 3 单测通过。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cd061aaaed
commit
3a65e8c58f
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
/// 查询销售产品列表。
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
51
src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs
Normal file
51
src/Oms.Application/Shared/PriceAreaSnapshotHelper.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
@ -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()
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user