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 Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
@ -44,6 +45,19 @@ public partial class OrderService
|
||||
|
||||
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 ICustomerExportQualificationRepository ExportQualificationRepository =>
|
||||
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
|
||||
@ -553,6 +567,30 @@ public partial class OrderService
|
||||
{
|
||||
var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, 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;
|
||||
if (rulesRecord == null)
|
||||
@ -599,9 +637,25 @@ public partial class OrderService
|
||||
Price = priceSnapshot
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
private static string BuildSnapshotBindingCacheKey(int productId, string rulesVersion, string priceVersion) =>
|
||||
$"sp-bind|{productId}|{rulesVersion}|{priceVersion}";
|
||||
|
||||
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
|
||||
{
|
||||
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
|
||||
@ -1908,13 +1962,21 @@ public partial class OrderService
|
||||
}
|
||||
else
|
||||
{
|
||||
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value);
|
||||
if (weightRule == null)
|
||||
await WeightRoundingDbGate.WaitAsync();
|
||||
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);
|
||||
@ -1927,14 +1989,11 @@ public partial class OrderService
|
||||
}
|
||||
|
||||
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
||||
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||||
|
||||
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
||||
forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync(
|
||||
rawWeight,
|
||||
ranges,
|
||||
rulesSnapshot,
|
||||
weightRoundingId.Value,
|
||||
dbRanges);
|
||||
weightRoundingId.Value);
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
@ -1959,31 +2018,20 @@ public partial class OrderService
|
||||
return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
|
||||
}
|
||||
|
||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
|
||||
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));
|
||||
await WeightRoundingDbGate.WaitAsync();
|
||||
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 dbRanges = ranges;
|
||||
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
||||
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)))
|
||||
{
|
||||
dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
||||
forecast.ChargeableWeight = await ApplyRoundingPreferSnapshotAsync(
|
||||
actualWeight,
|
||||
ranges,
|
||||
rulesSnapshot,
|
||||
weightRoundingId.Value,
|
||||
dbRanges);
|
||||
weightRoundingId.Value);
|
||||
}
|
||||
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)
|
||||
{
|
||||
if (product.IsActive != EnableStatus.Enabled)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fake.Application;
|
||||
using Oms.Application.Dtos.Marketing;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
@ -274,99 +275,161 @@ public partial class OrderService
|
||||
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 warehouse in warehouses)
|
||||
{
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
workItems.Add(new PriceCheckWorkItem(product, cargo, warehouse));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record PriceCheckWorkItem(
|
||||
SalesProduct Product,
|
||||
PriceCheckCargoTarget Cargo,
|
||||
WarehouseAddress Warehouse);
|
||||
|
||||
private async Task ApplyMarketingToPriceCheckResultAsync(OrderPriceCheckInput input, OrderPriceCheckResultOutput result)
|
||||
{
|
||||
if (result.Items.Count == 0)
|
||||
@ -574,21 +637,35 @@ public partial class OrderService
|
||||
string? priceTierKey,
|
||||
DateTime bindTime,
|
||||
string cargoTypeName,
|
||||
string warehouseName)
|
||||
string warehouseName,
|
||||
ProductSnapshotBinding? preloadedBinding = null,
|
||||
string? preloadedBindingError = null)
|
||||
{
|
||||
if (product.IsActive != EnableStatus.Enabled)
|
||||
{
|
||||
return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName);
|
||||
}
|
||||
|
||||
ProductSnapshotBinding snapshotBinding;
|
||||
try
|
||||
if (!string.IsNullOrWhiteSpace(preloadedBindingError))
|
||||
{
|
||||
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(
|
||||
|
||||
@ -77,4 +77,13 @@ public interface ISalesProductRepository : IRepository<SalesProduct>
|
||||
string operatorType,
|
||||
DateTime bindTime,
|
||||
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();
|
||||
}
|
||||
|
||||
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(
|
||||
SqlSugar.ISqlSugarClient db,
|
||||
int priceId,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user