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>
|
/// </summary>
|
||||||
public int SalesProductId { get; set; }
|
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>
|
||||||
/// 价格项列表。
|
/// 价格项列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Oms.Application.Dtos.Channels;
|
using Oms.Application.Dtos.Channels;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Domain.ChannelsAggregate.Consts;
|
using Oms.Domain.ChannelsAggregate.Consts;
|
||||||
@ -160,13 +161,20 @@ public partial class SalesProductService
|
|||||||
private async Task CreatePublishRecordAsync(int salesProductId, string operatorType, string? description)
|
private async Task CreatePublishRecordAsync(int salesProductId, string operatorType, string? description)
|
||||||
{
|
{
|
||||||
var product = await GetProductOrThrowAsync(salesProductId);
|
var product = await GetProductOrThrowAsync(salesProductId);
|
||||||
object snapshot = operatorType == PriceOperatorType
|
object snapshot;
|
||||||
? new SalesProductPricePublishSnapshot
|
if (operatorType == PriceOperatorType)
|
||||||
|
{
|
||||||
|
var priceSnapshot = new SalesProductPricePublishSnapshot
|
||||||
{
|
{
|
||||||
SalesProductId = salesProductId,
|
SalesProductId = salesProductId,
|
||||||
Prices = await GetPriceOutputsAsync(salesProductId)
|
Prices = await GetPriceOutputsAsync(salesProductId)
|
||||||
|
};
|
||||||
|
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(priceSnapshot, product, KpiAreaRepository);
|
||||||
|
snapshot = priceSnapshot;
|
||||||
}
|
}
|
||||||
: new SalesProductRulesPublishSnapshot
|
else
|
||||||
|
{
|
||||||
|
snapshot = new SalesProductRulesPublishSnapshot
|
||||||
{
|
{
|
||||||
Product = new SalesProductSaveInput
|
Product = new SalesProductSaveInput
|
||||||
{
|
{
|
||||||
@ -189,6 +197,7 @@ public partial class SalesProductService
|
|||||||
},
|
},
|
||||||
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
|
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。
|
// Content 中保存完整快照,回滚时直接从快照恢复,避免依赖当前库内状态。
|
||||||
await SalesProductRepository.InsertPublishRecordAsync(new SalesProductPublishRecord
|
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
|
private sealed class SalesProductRulesPublishSnapshot
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public partial class SalesProductService : ApplicationService
|
|||||||
|
|
||||||
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||||||
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
|
private IServiceChannelRepository ServiceChannelRepository => LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
|
||||||
|
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询销售产品列表。
|
/// 查询销售产品列表。
|
||||||
|
|||||||
@ -89,6 +89,7 @@ public partial class OrderService
|
|||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
snapshotBinding.Rules,
|
||||||
|
snapshotBinding.Price,
|
||||||
country.CountryCode,
|
country.CountryCode,
|
||||||
input.Receiver?.Province,
|
input.Receiver?.Province,
|
||||||
input.Receiver?.City,
|
input.Receiver?.City,
|
||||||
@ -317,7 +318,7 @@ public partial class OrderService
|
|||||||
.GroupBy(x => x.PriceId)
|
.GroupBy(x => x.PriceId)
|
||||||
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
|
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
|
||||||
|
|
||||||
return new SalesProductPricePublishSnapshotDto
|
var snapshot = new SalesProductPricePublishSnapshotDto
|
||||||
{
|
{
|
||||||
SalesProductId = product.Id,
|
SalesProductId = product.Id,
|
||||||
Prices = prices.Select(price => new SalesProductPriceListOutput
|
Prices = prices.Select(price => new SalesProductPriceListOutput
|
||||||
@ -340,6 +341,8 @@ public partial class OrderService
|
|||||||
: []
|
: []
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
|
await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository);
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity)
|
private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity)
|
||||||
@ -411,7 +414,8 @@ public partial class OrderService
|
|||||||
|
|
||||||
private static int? ResolveEffectivePriceAreaId(
|
private static int? ResolveEffectivePriceAreaId(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
|
SalesProductPricePublishSnapshotDto? priceSnapshot)
|
||||||
{
|
{
|
||||||
if (product.PriceAreaId is > 0)
|
if (product.PriceAreaId is > 0)
|
||||||
{
|
{
|
||||||
@ -423,18 +427,34 @@ public partial class OrderService
|
|||||||
return rulesSnapshot.Product.PriceAreaId;
|
return rulesSnapshot.Product.PriceAreaId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (priceSnapshot?.PriceAreaId is > 0)
|
||||||
|
{
|
||||||
|
return priceSnapshot.PriceAreaId;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<string>?> ResolvePriceZoneKeysAsync(
|
private async Task<IReadOnlyList<string>?> ResolvePriceZoneKeysAsync(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
|
SalesProductPricePublishSnapshotDto? priceSnapshot,
|
||||||
string countryCode,
|
string countryCode,
|
||||||
string? province,
|
string? province,
|
||||||
string? city,
|
string? city,
|
||||||
string? zipCode)
|
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)
|
if (!priceAreaId.HasValue)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -217,7 +217,7 @@ public partial class OrderService
|
|||||||
return PriceCheckFailure(product, "weight", ex.Message);
|
return PriceCheckFailure(product, "weight", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules);
|
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
|
||||||
if (!priceAreaId.HasValue)
|
if (!priceAreaId.HasValue)
|
||||||
{
|
{
|
||||||
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
|
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
|
||||||
@ -233,6 +233,7 @@ public partial class OrderService
|
|||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
snapshotBinding.Rules,
|
||||||
|
snapshotBinding.Price,
|
||||||
context.CountryCode,
|
context.CountryCode,
|
||||||
context.Province,
|
context.Province,
|
||||||
context.City,
|
context.City,
|
||||||
@ -246,8 +247,8 @@ public partial class OrderService
|
|||||||
return PriceCheckFailure(
|
return PriceCheckFailure(
|
||||||
product,
|
product,
|
||||||
"zone",
|
"zone",
|
||||||
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区规则(PriceAreaId={priceAreaId})," +
|
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," +
|
||||||
"无法解析与卖价一致的分区键,请检查产品 PriceAreaId 及 t_kpi_area_info 国家/邮编配置");
|
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致");
|
||||||
}
|
}
|
||||||
|
|
||||||
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
||||||
|
|||||||
@ -122,6 +122,40 @@ internal static class ParcelBillingCalculator
|
|||||||
return true;
|
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)
|
public static string? ResolveZoneName(KpiAreaInfo? matched, KpiArea? area)
|
||||||
{
|
{
|
||||||
if (matched == null)
|
if (matched == null)
|
||||||
@ -187,6 +221,75 @@ internal static class ParcelBillingCalculator
|
|||||||
return keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
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(
|
public static List<OrderFee> CalculateQuoteReceivableFees(
|
||||||
SalesProductPricePublishSnapshotDto priceSnapshot,
|
SalesProductPricePublishSnapshotDto priceSnapshot,
|
||||||
string priceVersion,
|
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);
|
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]
|
[Fact]
|
||||||
public void ExplainQuotePriceMiss_ZoneMismatch_ShouldNameEachPriceItem()
|
public void ExplainQuotePriceMiss_ZoneMismatch_ShouldNameEachPriceItem()
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user