perf: 加速查价——批量快照、绑定缓存与并行算价
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5af567b519
commit
6be97e7232
@ -3,6 +3,7 @@ using Fake.Application;
|
|||||||
using Fake.UnitOfWork;
|
using Fake.UnitOfWork;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@ -44,6 +45,19 @@ public partial class OrderService
|
|||||||
|
|
||||||
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new();
|
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已发布规则+价格快照绑定缓存(按 productId+version);LIVE 不入缓存。
|
||||||
|
/// </summary>
|
||||||
|
private static readonly MemoryCache SnapshotBindingCache = new(new MemoryCacheOptions
|
||||||
|
{
|
||||||
|
SizeLimit = 256
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计重进位回读 DB 时串行化,避免查价并行路径共用 SqlSugar 连接。
|
||||||
|
/// </summary>
|
||||||
|
private static readonly SemaphoreSlim WeightRoundingDbGate = new(1, 1);
|
||||||
|
|
||||||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||||
private ICustomerExportQualificationRepository ExportQualificationRepository =>
|
private ICustomerExportQualificationRepository ExportQualificationRepository =>
|
||||||
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
|
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
|
||||||
@ -553,6 +567,30 @@ public partial class OrderService
|
|||||||
{
|
{
|
||||||
var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime);
|
var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime);
|
||||||
var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime);
|
var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime);
|
||||||
|
return await BuildSnapshotBindingAsync(product, bindTime, rulesRecord, priceRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 由已查到的发布记录组装绑定;已发布版本命中进程内 MemoryCache。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ProductSnapshotBinding> BuildSnapshotBindingAsync(
|
||||||
|
SalesProduct product,
|
||||||
|
DateTime bindTime,
|
||||||
|
SalesProductPublishRecord? rulesRecord,
|
||||||
|
SalesProductPublishRecord? priceRecord)
|
||||||
|
{
|
||||||
|
var canCache = rulesRecord != null
|
||||||
|
&& priceRecord != null
|
||||||
|
&& !string.IsNullOrWhiteSpace(rulesRecord.Version)
|
||||||
|
&& !string.IsNullOrWhiteSpace(priceRecord.Version);
|
||||||
|
if (canCache)
|
||||||
|
{
|
||||||
|
var cacheKey = BuildSnapshotBindingCacheKey(product.Id, rulesRecord!.Version, priceRecord!.Version);
|
||||||
|
if (SnapshotBindingCache.TryGetValue(cacheKey, out ProductSnapshotBinding? cached) && cached != null)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SalesProductRulesPublishSnapshotDto rulesSnapshot;
|
SalesProductRulesPublishSnapshotDto rulesSnapshot;
|
||||||
if (rulesRecord == null)
|
if (rulesRecord == null)
|
||||||
@ -599,9 +637,25 @@ public partial class OrderService
|
|||||||
Price = priceSnapshot
|
Price = priceSnapshot
|
||||||
};
|
};
|
||||||
await KpiAreaPackService.HydrateBindingAsync(binding);
|
await KpiAreaPackService.HydrateBindingAsync(binding);
|
||||||
|
|
||||||
|
if (canCache)
|
||||||
|
{
|
||||||
|
SnapshotBindingCache.Set(
|
||||||
|
BuildSnapshotBindingCacheKey(product.Id, rulesRecord!.Version, priceRecord!.Version),
|
||||||
|
binding,
|
||||||
|
new MemoryCacheEntryOptions
|
||||||
|
{
|
||||||
|
Size = 1,
|
||||||
|
SlidingExpiration = TimeSpan.FromMinutes(10)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return binding;
|
return binding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildSnapshotBindingCacheKey(int productId, string rulesVersion, string priceVersion) =>
|
||||||
|
$"sp-bind|{productId}|{rulesVersion}|{priceVersion}";
|
||||||
|
|
||||||
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
|
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
|
||||||
{
|
{
|
||||||
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
|
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
|
||||||
@ -1908,13 +1962,21 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value);
|
await WeightRoundingDbGate.WaitAsync();
|
||||||
if (weightRule == null)
|
try
|
||||||
{
|
{
|
||||||
throw new BusinessException("未配置计重规则");
|
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value);
|
||||||
}
|
if (weightRule == null)
|
||||||
|
{
|
||||||
|
throw new BusinessException("未配置计重规则");
|
||||||
|
}
|
||||||
|
|
||||||
ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value);
|
ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WeightRoundingDbGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
||||||
@ -1927,14 +1989,11 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
||||||
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync(
|
||||||
|
|
||||||
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
|
||||||
rawWeight,
|
rawWeight,
|
||||||
ranges,
|
ranges,
|
||||||
rulesSnapshot,
|
rulesSnapshot,
|
||||||
weightRoundingId.Value,
|
weightRoundingId.Value);
|
||||||
dbRanges);
|
|
||||||
}
|
}
|
||||||
catch (BusinessException)
|
catch (BusinessException)
|
||||||
{
|
{
|
||||||
@ -1959,31 +2018,20 @@ public partial class OrderService
|
|||||||
return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
|
return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
|
||||||
}
|
}
|
||||||
|
|
||||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
|
await WeightRoundingDbGate.WaitAsync();
|
||||||
if (rounding == null)
|
|
||||||
{
|
|
||||||
throw new BusinessException("未配置计重进位");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static decimal ApplyRoundingWithSnapshotFallback(
|
|
||||||
decimal rawWeight,
|
|
||||||
IReadOnlyList<WeightRoundingInfo> ranges,
|
|
||||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
|
||||||
int weightRoundingId,
|
|
||||||
IReadOnlyList<WeightRoundingInfo> dbFallbackRanges)
|
|
||||||
{
|
|
||||||
var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
|
||||||
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
|
||||||
|
if (rounding == null)
|
||||||
|
{
|
||||||
|
throw new BusinessException("未配置计重进位");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0)
|
finally
|
||||||
{
|
{
|
||||||
return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges);
|
WeightRoundingDbGate.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2011,21 +2059,13 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
||||||
var dbRanges = ranges;
|
|
||||||
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
|
||||||
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)))
|
|
||||||
{
|
|
||||||
dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync(
|
||||||
actualWeight,
|
actualWeight,
|
||||||
ranges,
|
ranges,
|
||||||
rulesSnapshot,
|
rulesSnapshot,
|
||||||
weightRoundingId.Value,
|
weightRoundingId.Value);
|
||||||
dbRanges);
|
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
@ -2033,6 +2073,41 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先用快照进位;仅失败时回读 DB(串行门闩,兼容查价并行)。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<decimal> ApplyRoundingPreferSnapshotAsync(
|
||||||
|
decimal rawWeight,
|
||||||
|
IReadOnlyList<WeightRoundingInfo> ranges,
|
||||||
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
|
int weightRoundingId)
|
||||||
|
{
|
||||||
|
var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
||||||
|
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException) when (fromSnapshot)
|
||||||
|
{
|
||||||
|
await WeightRoundingDbGate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
|
||||||
|
if (dbRanges.Count == 0)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WeightRoundingDbGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureBillingProductActive(SalesProduct product)
|
private static void EnsureBillingProductActive(SalesProduct product)
|
||||||
{
|
{
|
||||||
if (product.IsActive != EnableStatus.Enabled)
|
if (product.IsActive != EnableStatus.Enabled)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Oms.Application.Dtos.Marketing;
|
using Oms.Application.Dtos.Marketing;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
@ -274,99 +275,161 @@ public partial class OrderService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var product in products.OrderBy(x => x.Id))
|
var orderedProducts = products.OrderBy(x => x.Id).ToList();
|
||||||
|
var productIds = orderedProducts.Select(x => x.Id).ToList();
|
||||||
|
// 批量拉发布记录(2 次查询替代 2N),再组装绑定;SqlSugar 同连接不可并发查库
|
||||||
|
var rulesMap = await SalesProductRepository.GetEffectivePublishRecordsAsync(
|
||||||
|
productIds, RulesOperatorType, bindTime);
|
||||||
|
var priceMap = await SalesProductRepository.GetEffectivePublishRecordsAsync(
|
||||||
|
productIds, PriceOperatorType, bindTime);
|
||||||
|
|
||||||
|
var bindingByProductId = new Dictionary<int, ProductSnapshotBinding>(orderedProducts.Count);
|
||||||
|
var bindingErrorByProductId = new Dictionary<int, string>();
|
||||||
|
foreach (var product in orderedProducts)
|
||||||
|
{
|
||||||
|
rulesMap.TryGetValue(product.Id, out var rulesRecord);
|
||||||
|
priceMap.TryGetValue(product.Id, out var priceRecord);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bindingByProductId[product.Id] = await BuildSnapshotBindingAsync(
|
||||||
|
product, bindTime, rulesRecord, priceRecord);
|
||||||
|
}
|
||||||
|
catch (BusinessException ex)
|
||||||
|
{
|
||||||
|
bindingErrorByProductId[product.Id] = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var workItems = new List<PriceCheckWorkItem>(
|
||||||
|
orderedProducts.Count * Math.Max(1, cargoTargets.Count) * Math.Max(1, warehouses.Count));
|
||||||
|
foreach (var product in orderedProducts)
|
||||||
{
|
{
|
||||||
foreach (var cargo in cargoTargets)
|
foreach (var cargo in cargoTargets)
|
||||||
{
|
{
|
||||||
foreach (var warehouse in warehouses)
|
foreach (var warehouse in warehouses)
|
||||||
{
|
{
|
||||||
var quoteContext = new ParcelQuoteContext
|
workItems.Add(new PriceCheckWorkItem(product, cargo, warehouse));
|
||||||
{
|
|
||||||
CountryCode = baseContext.CountryCode,
|
|
||||||
Province = baseContext.Province,
|
|
||||||
City = baseContext.City,
|
|
||||||
PostalCode = baseContext.PostalCode,
|
|
||||||
CargoAttribute = cargo.CargoAttribute,
|
|
||||||
CargoTypeCode = cargo.Code,
|
|
||||||
WarehouseId = warehouse.Id,
|
|
||||||
WarehouseName = warehouse.WarehouseName,
|
|
||||||
Length = baseContext.Length,
|
|
||||||
Width = baseContext.Width,
|
|
||||||
Height = baseContext.Height,
|
|
||||||
ActualWeight = baseContext.ActualWeight,
|
|
||||||
AdditionalServices = baseContext.AdditionalServices,
|
|
||||||
DeclaredSum = baseContext.DeclaredSum,
|
|
||||||
DeclaredCurrency = baseContext.DeclaredCurrency
|
|
||||||
};
|
|
||||||
|
|
||||||
var attempt = await TryComputePriceCheckQuoteAsync(
|
|
||||||
product,
|
|
||||||
quoteContext,
|
|
||||||
profile,
|
|
||||||
priceTierKey,
|
|
||||||
bindTime,
|
|
||||||
cargo.DisplayName,
|
|
||||||
warehouse.WarehouseName);
|
|
||||||
|
|
||||||
if (attempt.Success)
|
|
||||||
{
|
|
||||||
var quote = attempt.Quote!;
|
|
||||||
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
|
|
||||||
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
|
|
||||||
var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup)
|
|
||||||
? UngroupedProductGroupCode
|
|
||||||
: product.ProductGroup.Trim();
|
|
||||||
var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName)
|
|
||||||
? mappedName
|
|
||||||
: groupCode;
|
|
||||||
result.Items.Add(new OrderPriceCheckOutput
|
|
||||||
{
|
|
||||||
Id = quote.ProductId,
|
|
||||||
ProductCode = quote.ProductCode,
|
|
||||||
ShippingMethod = quote.ProductName,
|
|
||||||
CargoType = cargo.Code,
|
|
||||||
CargoTypeName = cargo.DisplayName,
|
|
||||||
WarehouseId = warehouse.Id,
|
|
||||||
Warehouse = warehouse.WarehouseName,
|
|
||||||
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
|
||||||
TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms) ? null : product.TradeTerms.Trim().ToUpperInvariant(),
|
|
||||||
ProductGroup = groupCode,
|
|
||||||
ProductGroupName = groupName,
|
|
||||||
ProductDescription = string.IsNullOrWhiteSpace(product.ProductDescription)
|
|
||||||
? null
|
|
||||||
: product.ProductDescription.Trim(),
|
|
||||||
VolumetricWeight = quote.VolumetricWeight,
|
|
||||||
ChargeableWeight = quote.ChargeableWeight,
|
|
||||||
CostFormula = summary.CostFormula,
|
|
||||||
Freight = summary.Freight,
|
|
||||||
OtherFees = summary.OtherFees,
|
|
||||||
OtherFeeList = summary.OtherFeeList,
|
|
||||||
TotalCost = summary.TotalCost,
|
|
||||||
MaxDeliveryDays = product.MaxTimeliness,
|
|
||||||
TotalCostNum = summary.TotalCost,
|
|
||||||
RulesVersion = quote.RulesVersion,
|
|
||||||
PriceVersion = quote.PriceVersion,
|
|
||||||
IsRemote = quote.IsRemote,
|
|
||||||
QuoteLevel = itemHint.QuoteLevel,
|
|
||||||
MissingFieldCodes = itemHint.MissingFieldCodes,
|
|
||||||
Disclaimers = itemHint.Disclaimers,
|
|
||||||
ZoneName = quote.ZoneName
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt.Failure != null)
|
|
||||||
{
|
|
||||||
result.Failures.Add(attempt.Failure);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var itemBag = new ConcurrentBag<OrderPriceCheckOutput>();
|
||||||
|
var failureBag = new ConcurrentBag<OrderPriceCheckFailureOutput>();
|
||||||
|
var parallelOptions = new ParallelOptions
|
||||||
|
{
|
||||||
|
// 快照已预加载后主要为 CPU(分区匹配/算费);限制并发避免打满 CPU
|
||||||
|
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 2, 8)
|
||||||
|
};
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(workItems, parallelOptions, async (work, _) =>
|
||||||
|
{
|
||||||
|
var product = work.Product;
|
||||||
|
var cargo = work.Cargo;
|
||||||
|
var warehouse = work.Warehouse;
|
||||||
|
bindingByProductId.TryGetValue(product.Id, out var snapshotBinding);
|
||||||
|
bindingErrorByProductId.TryGetValue(product.Id, out var snapshotError);
|
||||||
|
|
||||||
|
var quoteContext = new ParcelQuoteContext
|
||||||
|
{
|
||||||
|
CountryCode = baseContext.CountryCode,
|
||||||
|
Province = baseContext.Province,
|
||||||
|
City = baseContext.City,
|
||||||
|
PostalCode = baseContext.PostalCode,
|
||||||
|
CargoAttribute = cargo.CargoAttribute,
|
||||||
|
CargoTypeCode = cargo.Code,
|
||||||
|
WarehouseId = warehouse.Id,
|
||||||
|
WarehouseName = warehouse.WarehouseName,
|
||||||
|
Length = baseContext.Length,
|
||||||
|
Width = baseContext.Width,
|
||||||
|
Height = baseContext.Height,
|
||||||
|
ActualWeight = baseContext.ActualWeight,
|
||||||
|
AdditionalServices = baseContext.AdditionalServices,
|
||||||
|
DeclaredSum = baseContext.DeclaredSum,
|
||||||
|
DeclaredCurrency = baseContext.DeclaredCurrency
|
||||||
|
};
|
||||||
|
|
||||||
|
var attempt = await TryComputePriceCheckQuoteAsync(
|
||||||
|
product,
|
||||||
|
quoteContext,
|
||||||
|
profile,
|
||||||
|
priceTierKey,
|
||||||
|
bindTime,
|
||||||
|
cargo.DisplayName,
|
||||||
|
warehouse.WarehouseName,
|
||||||
|
snapshotBinding,
|
||||||
|
snapshotError);
|
||||||
|
|
||||||
|
if (attempt.Success)
|
||||||
|
{
|
||||||
|
var quote = attempt.Quote!;
|
||||||
|
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
|
||||||
|
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
|
||||||
|
var groupCode = string.IsNullOrWhiteSpace(product.ProductGroup)
|
||||||
|
? UngroupedProductGroupCode
|
||||||
|
: product.ProductGroup.Trim();
|
||||||
|
var groupName = productGroupNameMap.TryGetValue(groupCode, out var mappedName)
|
||||||
|
? mappedName
|
||||||
|
: groupCode;
|
||||||
|
itemBag.Add(new OrderPriceCheckOutput
|
||||||
|
{
|
||||||
|
Id = quote.ProductId,
|
||||||
|
ProductCode = quote.ProductCode,
|
||||||
|
ShippingMethod = quote.ProductName,
|
||||||
|
CargoType = cargo.Code,
|
||||||
|
CargoTypeName = cargo.DisplayName,
|
||||||
|
WarehouseId = warehouse.Id,
|
||||||
|
Warehouse = warehouse.WarehouseName,
|
||||||
|
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
|
||||||
|
TradeTerms = string.IsNullOrWhiteSpace(product.TradeTerms)
|
||||||
|
? null
|
||||||
|
: product.TradeTerms.Trim().ToUpperInvariant(),
|
||||||
|
ProductGroup = groupCode,
|
||||||
|
ProductGroupName = groupName,
|
||||||
|
ProductDescription = string.IsNullOrWhiteSpace(product.ProductDescription)
|
||||||
|
? null
|
||||||
|
: product.ProductDescription.Trim(),
|
||||||
|
VolumetricWeight = quote.VolumetricWeight,
|
||||||
|
ChargeableWeight = quote.ChargeableWeight,
|
||||||
|
CostFormula = summary.CostFormula,
|
||||||
|
Freight = summary.Freight,
|
||||||
|
OtherFees = summary.OtherFees,
|
||||||
|
OtherFeeList = summary.OtherFeeList,
|
||||||
|
TotalCost = summary.TotalCost,
|
||||||
|
MaxDeliveryDays = product.MaxTimeliness,
|
||||||
|
TotalCostNum = summary.TotalCost,
|
||||||
|
RulesVersion = quote.RulesVersion,
|
||||||
|
PriceVersion = quote.PriceVersion,
|
||||||
|
IsRemote = quote.IsRemote,
|
||||||
|
QuoteLevel = itemHint.QuoteLevel,
|
||||||
|
MissingFieldCodes = itemHint.MissingFieldCodes,
|
||||||
|
Disclaimers = itemHint.Disclaimers,
|
||||||
|
ZoneName = quote.ZoneName
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.Failure != null)
|
||||||
|
{
|
||||||
|
failureBag.Add(attempt.Failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
result.Items.AddRange(
|
||||||
|
itemBag
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.ThenBy(x => x.CargoType, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(x => x.WarehouseId));
|
||||||
|
result.Failures.AddRange(failureBag);
|
||||||
|
|
||||||
await ApplyMarketingToPriceCheckResultAsync(input, result);
|
await ApplyMarketingToPriceCheckResultAsync(input, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record PriceCheckWorkItem(
|
||||||
|
SalesProduct Product,
|
||||||
|
PriceCheckCargoTarget Cargo,
|
||||||
|
WarehouseAddress Warehouse);
|
||||||
|
|
||||||
private async Task ApplyMarketingToPriceCheckResultAsync(OrderPriceCheckInput input, OrderPriceCheckResultOutput result)
|
private async Task ApplyMarketingToPriceCheckResultAsync(OrderPriceCheckInput input, OrderPriceCheckResultOutput result)
|
||||||
{
|
{
|
||||||
if (result.Items.Count == 0)
|
if (result.Items.Count == 0)
|
||||||
@ -574,21 +637,35 @@ public partial class OrderService
|
|||||||
string? priceTierKey,
|
string? priceTierKey,
|
||||||
DateTime bindTime,
|
DateTime bindTime,
|
||||||
string cargoTypeName,
|
string cargoTypeName,
|
||||||
string warehouseName)
|
string warehouseName,
|
||||||
|
ProductSnapshotBinding? preloadedBinding = null,
|
||||||
|
string? preloadedBindingError = null)
|
||||||
{
|
{
|
||||||
if (product.IsActive != EnableStatus.Enabled)
|
if (product.IsActive != EnableStatus.Enabled)
|
||||||
{
|
{
|
||||||
return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName);
|
return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
ProductSnapshotBinding snapshotBinding;
|
if (!string.IsNullOrWhiteSpace(preloadedBindingError))
|
||||||
try
|
|
||||||
{
|
{
|
||||||
snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
return PriceCheckFailure(product, "snapshot", preloadedBindingError, cargoTypeName, warehouseName);
|
||||||
}
|
}
|
||||||
catch (BusinessException ex)
|
|
||||||
|
ProductSnapshotBinding snapshotBinding;
|
||||||
|
if (preloadedBinding != null)
|
||||||
{
|
{
|
||||||
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
|
snapshotBinding = preloadedBinding;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||||||
|
}
|
||||||
|
catch (BusinessException ex)
|
||||||
|
{
|
||||||
|
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await IsBlacklistHitAsync(
|
if (await IsBlacklistHitAsync(
|
||||||
|
|||||||
@ -77,4 +77,13 @@ public interface ISalesProductRepository : IRepository<SalesProduct>
|
|||||||
string operatorType,
|
string operatorType,
|
||||||
DateTime bindTime,
|
DateTime bindTime,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量取各产品在 bindTime 时点生效的最新 Active 发布记录(含 Content)。
|
||||||
|
/// </summary>
|
||||||
|
Task<Dictionary<int, SalesProductPublishRecord>> GetEffectivePublishRecordsAsync(
|
||||||
|
IReadOnlyCollection<int> salesProductIds,
|
||||||
|
string operatorType,
|
||||||
|
DateTime bindTime,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -288,6 +288,54 @@ public class SalesProductRepository : OmsSqlSugarRepository<SalesProduct>, ISale
|
|||||||
return records.FirstOrDefault();
|
return records.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<int, SalesProductPublishRecord>> GetEffectivePublishRecordsAsync(
|
||||||
|
IReadOnlyCollection<int> salesProductIds,
|
||||||
|
string operatorType,
|
||||||
|
DateTime bindTime,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ids = salesProductIds.Where(x => x > 0).Distinct().ToArray();
|
||||||
|
if (ids.Length == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<int, SalesProductPublishRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var db = await GetDbContextAsync(cancellationToken);
|
||||||
|
// 先只取头字段,避免把历史版本的 Content 一并拉回
|
||||||
|
var headers = await db.Queryable<SalesProductPublishRecord>()
|
||||||
|
.Where(x => ids.Contains(x.SalesProductId)
|
||||||
|
&& x.OperatorType == operatorType
|
||||||
|
&& x.Status == EsSnapshotStatus.Active
|
||||||
|
&& x.PublishTime <= bindTime)
|
||||||
|
.Select(x => new SalesProductPublishRecord
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
SalesProductId = x.SalesProductId,
|
||||||
|
PublishTime = x.PublishTime
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (headers.Count == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<int, SalesProductPublishRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var winningIds = headers
|
||||||
|
.GroupBy(x => x.SalesProductId)
|
||||||
|
.Select(g => g.OrderByDescending(x => x.PublishTime).ThenByDescending(x => x.Id).First().Id)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var records = await db.Queryable<SalesProductPublishRecord>()
|
||||||
|
.Where(x => winningIds.Contains(x.Id))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return records
|
||||||
|
.GroupBy(x => x.SalesProductId)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.OrderByDescending(x => x.PublishTime).ThenByDescending(x => x.Id).First());
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task InsertPriceChildrenAsync(
|
private static async Task InsertPriceChildrenAsync(
|
||||||
SqlSugar.ISqlSugarClient db,
|
SqlSugar.ISqlSugarClient db,
|
||||||
int priceId,
|
int priceId,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user