From 6be97e7232e261d2c40d847eab32df0fce954e80 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 26 Oct 2026 18:06:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?perf:=20=E5=8A=A0=E9=80=9F=E6=9F=A5?= =?UTF-8?q?=E4=BB=B7=E2=80=94=E2=80=94=E6=89=B9=E9=87=8F=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E3=80=81=E7=BB=91=E5=AE=9A=E7=BC=93=E5=AD=98=E4=B8=8E=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E7=AE=97=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Orders/OrderService.ParcelBilling.cs | 157 ++++++++--- .../Orders/OrderService.ParcelQuote.cs | 249 ++++++++++++------ .../Repositories/ISalesProductRepository.cs | 9 + .../Channels/SalesProductRepository.cs | 48 ++++ 4 files changed, 336 insertions(+), 127 deletions(-) diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index a364dbf..3237847 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -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 InboundBillingInFlight = new(); + /// + /// 已发布规则+价格快照绑定缓存(按 productId+version);LIVE 不入缓存。 + /// + private static readonly MemoryCache SnapshotBindingCache = new(new MemoryCacheOptions + { + SizeLimit = 256 + }); + + /// + /// 计重进位回读 DB 时串行化,避免查价并行路径共用 SqlSugar 连接。 + /// + private static readonly SemaphoreSlim WeightRoundingDbGate = new(1, 1); + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private ICustomerExportQualificationRepository ExportQualificationRepository => LazyServiceProvider.GetRequiredService(); @@ -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); + } + + /// + /// 由已查到的发布记录组装绑定;已发布版本命中进程内 MemoryCache。 + /// + private async Task 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 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 ranges, - SalesProductRulesPublishSnapshotDto? rulesSnapshot, - int weightRoundingId, - IReadOnlyList 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 } } + /// + /// 优先用快照进位;仅失败时回读 DB(串行门闩,兼容查价并行)。 + /// + private async Task ApplyRoundingPreferSnapshotAsync( + decimal rawWeight, + IReadOnlyList 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) diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 929f1db..03e7300 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -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(orderedProducts.Count); + var bindingErrorByProductId = new Dictionary(); + 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( + 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(); + var failureBag = new ConcurrentBag(); + 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( diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs index e72dc7f..606284b 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/ISalesProductRepository.cs @@ -77,4 +77,13 @@ public interface ISalesProductRepository : IRepository string operatorType, DateTime bindTime, CancellationToken cancellationToken = default); + + /// + /// 批量取各产品在 bindTime 时点生效的最新 Active 发布记录(含 Content)。 + /// + Task> GetEffectivePublishRecordsAsync( + IReadOnlyCollection salesProductIds, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs index 6fcf92a..f8b1be3 100644 --- a/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/SalesProductRepository.cs @@ -288,6 +288,54 @@ public class SalesProductRepository : OmsSqlSugarRepository, ISale return records.FirstOrDefault(); } + public async Task> GetEffectivePublishRecordsAsync( + IReadOnlyCollection salesProductIds, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default) + { + var ids = salesProductIds.Where(x => x > 0).Distinct().ToArray(); + if (ids.Length == 0) + { + return new Dictionary(); + } + + var db = await GetDbContextAsync(cancellationToken); + // 先只取头字段,避免把历史版本的 Content 一并拉回 + var headers = await db.Queryable() + .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(); + } + + 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() + .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, -- 2.45.2 From fef409a4ce18c366bd686b9c67f9befc513d1fdf Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 26 Oct 2026 18:35:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=8F=AA=E6=94=B9=E7=8A=B6=E6=80=81=EF=BC=8C=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20Tab=20=E5=8F=AF=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Services/Orders/OrderService.cs | 7 +++++-- .../Orders/OrderRepository.cs | 21 ++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 9f6dd5a..b8d113a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -108,7 +108,10 @@ public partial class OrderService : ApplicationService StringComparer.OrdinalIgnoreCase); var output = new OrderStatusCountsOutput { - Total = rows.Sum(x => x.Count), + // 「全部」不含已删除;已删除单独走 deleted Tab + Total = rows + .Where(x => !string.Equals(x.Status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + .Sum(x => x.Count), Counts = counts, HandleCounts = handleCounts }; @@ -441,6 +444,7 @@ public partial class OrderService : ApplicationService throw new BusinessException("仅草稿或已预报订单可删除"); } + // 业务删除:只改 OrderStatus=deleted,不做 IsDeleted 软删,否则「已删除」Tab 因全局软删过滤永远查不到。 order.OrderStatus = OrderStatusCodes.Deleted; if (order.IsPaid > 0 && order.CustomerId.HasValue) { @@ -460,7 +464,6 @@ public partial class OrderService : ApplicationService } await OrderRepository.UpdateAsync(order); - await OrderRepository.DeleteAsync(order); } /// diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 9819fbd..90288bc 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -48,8 +48,18 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I } var status = filter.Status?.Trim(); - if (!string.IsNullOrWhiteSpace(status) - && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) + var isDeletedTab = string.Equals(status, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase); + if (isDeletedTab) + { + // 历史删除曾同时软删(IsDeleted=true),需清过滤才能在「已删除」看到 + query = query.ClearFilter().Where(x => x.OrderStatus == OrderStatusCodes.Deleted); + } + else if (string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) + { + // 列表「全部」不含已删除;status 为空时留给 status-counts 聚合(需含 deleted 桶) + query = query.Where(x => x.OrderStatus != OrderStatusCodes.Deleted); + } + else if (!string.IsNullOrWhiteSpace(status)) { query = query.Where(x => x.OrderStatus == status); } @@ -1352,7 +1362,12 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); - var query = ApplyListFilter(db.Queryable(), filter); + // 含历史「状态=deleted 且已软删」的单据,保证已删除 Tab 角标可见 + var query = ApplyListFilter( + db.Queryable() + .ClearFilter() + .Where(x => !x.IsDeleted || x.OrderStatus == OrderStatusCodes.Deleted), + filter); return await query .GroupBy(x => x.OrderStatus) -- 2.45.2 From ff9a3ae8be0e191d17d9db87ed4ddbdb7c098b5d Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 27 Oct 2026 09:46:28 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E8=AE=A2=E9=98=85=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E4=B8=AD=E5=BF=83=20TrackChange=EF=BC=8C=E6=8C=89=20D?= =?UTF-8?q?03/D09/D11=20=E5=90=8C=E6=AD=A5=E5=87=BA=E5=BA=93=E5=90=8E?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../SmallBagTrackServiceExtensions.cs | 5 + .../ITrackChangeOrderStatusApplier.cs | 12 ++ .../Messaging/TrackChange/TrackChangeEto.cs | 65 ++++++ .../TrackChangeOrderStatusApplier.cs | 168 +++++++++++++++ .../TrackChangeSubscribeOptions.cs | 43 ++++ .../TrackChangeSubscriberHostedService.cs | 202 ++++++++++++++++++ .../TrackStatusOrderStatusMapper.cs | 38 ++++ .../Consts/OrderOperationActionCodes.cs | 4 + src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs | 1 + .../TrackStatusOrderStatusMapperTests.cs | 29 +++ .../Support/SimpleAdminApiTestHost.cs | 3 +- 11 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 src/Oms.Application/Messaging/TrackChange/ITrackChangeOrderStatusApplier.cs create mode 100644 src/Oms.Application/Messaging/TrackChange/TrackChangeEto.cs create mode 100644 src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs create mode 100644 src/Oms.Application/Messaging/TrackChange/TrackChangeSubscribeOptions.cs create mode 100644 src/Oms.Application/Messaging/TrackChange/TrackChangeSubscriberHostedService.cs create mode 100644 src/Oms.Application/Messaging/TrackChange/TrackStatusOrderStatusMapper.cs create mode 100644 src/Oms.Tests/Messaging/TrackStatusOrderStatusMapperTests.cs diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs index 2427d8e..70b3728 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackServiceExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Oms.Application.Messaging.TrackChange; namespace Oms.Application.Integrations.SmallBagTrack; @@ -8,12 +9,16 @@ public static class SmallBagTrackServiceExtensions public static IServiceCollection AddOmsSmallBagTrack(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection(SmallBagTrackOptions.SectionName)); + services.Configure( + configuration.GetSection(TrackChangeSubscribeOptions.SectionName)); services.AddHttpClient(SmallBagTrackOrderClient.HttpClientName); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddScoped(); + services.AddHostedService(); return services; } } diff --git a/src/Oms.Application/Messaging/TrackChange/ITrackChangeOrderStatusApplier.cs b/src/Oms.Application/Messaging/TrackChange/ITrackChangeOrderStatusApplier.cs new file mode 100644 index 0000000..255fa9c --- /dev/null +++ b/src/Oms.Application/Messaging/TrackChange/ITrackChangeOrderStatusApplier.cs @@ -0,0 +1,12 @@ +namespace Oms.Application.Messaging.TrackChange; + +/// +/// 根据轨迹变更事件同步 OMS 出库后订单状态。 +/// +public interface ITrackChangeOrderStatusApplier +{ + /// + /// 处理一条 TrackChange 消息;业务跳过不抛异常。 + /// + Task ApplyAsync(TrackChangeEto eto, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeEto.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeEto.cs new file mode 100644 index 0000000..726ab20 --- /dev/null +++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeEto.cs @@ -0,0 +1,65 @@ +namespace Oms.Application.Messaging.TrackChange; + +/// +/// 轨迹中心轨迹变更广播(RabbitMQ topic:track.on_track_change_ex / TrackChange.{AppKey})。 +/// 字段与 TrackCenter.Application.Events.TrackChangeEto 对齐(Newtonsoft 默认 PascalCase)。 +/// +public sealed class TrackChangeEto +{ + /// 轨迹唯一 id;操作失败时可能为 null。 + public string? TrackEsId { get; set; } + + /// 操作类型:1 新增,2 删除,3 修改。 + public int Option { get; set; } + + /// 操作是否成功。 + public bool IsSuccess { get; set; } + + /// 失败原因。 + public string? FailedReason { get; set; } + + /// 应用 AppKey。 + public string? AppKey { get; set; } + + /// 单号(OMS 侧对应运单号 ServiceNo)。 + public string? OrderNo { get; set; } + + /// 客户标识。 + public string? Customer { get; set; } + + /// 操作人。 + public string? OperationMan { get; set; } + + /// 轨迹内容代码。 + public string? TrackCode { get; set; } + + /// 轨迹内容。 + public string? Content { get; set; } + + /// 轨迹内容英文。 + public string? EnStatus { get; set; } + + /// 轨迹状态代码(如 D03 / D09 / D11)。 + public string? TrackStatusCode { get; set; } + + /// 轨迹发生地。 + public string? TrackPlace { get; set; } + + /// 轨迹发生时间。 + public DateTime TrackTime { get; set; } + + /// 轨迹发生地时区。 + public float TrackTimeZone { get; set; } + + /// 是否对外。 + public bool IsExternal { get; set; } + + /// 数据来源:1 外部服务商,2 内部系统,3 人工。 + public int Source { get; set; } + + /// 轨迹备注。 + public string? Remarks { get; set; } + + /// 扩展信息。 + public string? Extra { get; set; } +} diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs new file mode 100644 index 0000000..e82a28c --- /dev/null +++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs @@ -0,0 +1,168 @@ +using Fake.DependencyInjection; +using Fake.UnitOfWork; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Messaging.TrackChange; + +/// +/// 将轨迹状态 D03/D09/D11 同步到运单订单状态(运输中 / 派送失败 / 派送成功)。 +/// 规则对齐手动「出库后状态校正」:仅出库后四态可改;派送成功只进不出。 +/// +public sealed class TrackChangeOrderStatusApplier( + IOrderRepository orderRepository, + OrderOperationLogWriter operationLogWriter, + ReferralCommissionManager referralCommissionManager, + MarketingCouponManager marketingCouponManager, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) : ITrackChangeOrderStatusApplier, IScopedDependency +{ + private static readonly HashSet PostShipStatuses = new(StringComparer.OrdinalIgnoreCase) + { + OrderStatusCodes.PendingOnline, + OrderStatusCodes.Shipping, + OrderStatusCodes.DeliverySuccess, + OrderStatusCodes.DeliveryFailed + }; + + public async Task ApplyAsync(TrackChangeEto eto, CancellationToken cancellationToken = default) + { + if (eto == null) + { + return; + } + + if (!eto.IsSuccess) + { + logger.LogDebug( + "TrackChange skip failed event OrderNo={OrderNo} Reason={Reason}", + eto.OrderNo, + eto.FailedReason); + return; + } + + // 删除轨迹不回退 OMS 状态 + if (eto.Option == 2) + { + logger.LogDebug("TrackChange skip delete Option=2 OrderNo={OrderNo}", eto.OrderNo); + return; + } + + var targetStatus = TrackStatusOrderStatusMapper.TryMapToOrderStatus(eto.TrackStatusCode); + if (targetStatus == null) + { + logger.LogDebug( + "TrackChange skip unmapped status OrderNo={OrderNo} TrackStatusCode={TrackStatusCode}", + eto.OrderNo, + eto.TrackStatusCode); + return; + } + + var orderNo = eto.OrderNo?.Trim(); + if (string.IsNullOrWhiteSpace(orderNo)) + { + logger.LogWarning("TrackChange skip empty OrderNo TrackStatusCode={TrackStatusCode}", eto.TrackStatusCode); + return; + } + + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); + + var order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken); + if (order == null) + { + logger.LogWarning( + "TrackChange order not found ServiceNo={OrderNo} TrackStatusCode={TrackStatusCode}", + orderNo, + eto.TrackStatusCode); + await unitOfWork.CompleteAsync(cancellationToken); + return; + } + + var fromStatus = order.OrderStatus?.Trim() ?? string.Empty; + if (!IsPostShipStatus(fromStatus)) + { + logger.LogInformation( + "TrackChange skip non post-ship status OrderId={OrderId} ServiceNo={ServiceNo} From={From} TrackStatusCode={TrackStatusCode}", + order.Id, + order.ServiceNo, + fromStatus, + eto.TrackStatusCode); + await unitOfWork.CompleteAsync(cancellationToken); + return; + } + + if (string.Equals(fromStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation( + "TrackChange skip deliverySuccess terminal OrderId={OrderId} ServiceNo={ServiceNo} TrackStatusCode={TrackStatusCode}", + order.Id, + order.ServiceNo, + eto.TrackStatusCode); + await unitOfWork.CompleteAsync(cancellationToken); + return; + } + + if (string.Equals(fromStatus, targetStatus, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "TrackChange idempotent skip OrderId={OrderId} ServiceNo={ServiceNo} Status={Status}", + order.Id, + order.ServiceNo, + targetStatus); + await unitOfWork.CompleteAsync(cancellationToken); + return; + } + + var beforeSnap = OrderOperationLogSnapshot.CaptureOrder(order); + order.OrderStatus = targetStatus; + order.UpdateTime = DateTime.Now; + if (string.Equals(targetStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)) + { + order.SignTime = DateTime.Now; + } + + await orderRepository.UpdateAsync(order, cancellationToken: cancellationToken); + + if (string.Equals(targetStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)) + { + await referralCommissionManager.OnOrderDeliverySuccessAsync(order.Id, cancellationToken); + // 重新加载,避免营销侧依赖最新字段 + order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; + await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken); + } + + order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; + var afterSnap = OrderOperationLogSnapshot.CaptureOrder(order); + var fromName = OmsDocHelper.GetOrderStatusName(fromStatus) ?? fromStatus; + var toName = OmsDocHelper.GetOrderStatusName(targetStatus) ?? targetStatus; + var trackHint = string.IsNullOrWhiteSpace(eto.Content) + ? eto.TrackStatusCode + : $"{eto.TrackStatusCode}/{eto.Content}"; + await operationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.TrackStatusSync, + beforeSnap, + afterSnap, + $"{fromName} → {toName};轨迹同步 {trackHint}", + operatorId: 0, + operatorName: string.IsNullOrWhiteSpace(eto.OperationMan) ? "轨迹中心" : eto.OperationMan.Trim(), + operatorType: "system", + source: "trackCenter"); + + await unitOfWork.CompleteAsync(cancellationToken); + + logger.LogInformation( + "TrackChange applied OrderId={OrderId} ServiceNo={ServiceNo} {From} → {To} TrackStatusCode={TrackStatusCode}", + order.Id, + order.ServiceNo, + fromStatus, + targetStatus, + eto.TrackStatusCode); + } + + private static bool IsPostShipStatus(string? status) => + !string.IsNullOrWhiteSpace(status) && PostShipStatuses.Contains(status.Trim()); +} diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeSubscribeOptions.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeSubscribeOptions.cs new file mode 100644 index 0000000..5ef9b1c --- /dev/null +++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeSubscribeOptions.cs @@ -0,0 +1,43 @@ +namespace Oms.Application.Messaging.TrackChange; + +/// +/// 订阅轨迹中心 TrackChange 广播的配置(Consul / 配置:Oms:TrackChangeSubscribe)。 +/// +public class TrackChangeSubscribeOptions +{ + public const string SectionName = "Oms:TrackChangeSubscribe"; + + /// 是否启用订阅;默认 true。 + public bool Enabled { get; set; } = true; + + /// + /// 轨迹中心广播交换器;须与 TrackCenter GlobalConst.TrackChangeExchange 一致。 + /// + public string Exchange { get; set; } = "track.on_track_change_ex"; + + /// + /// 绑定路由键模板,{AppKey} 会替换为轨迹服务 AppKey。 + /// + public string RoutingKeyTemplate { get; set; } = "TrackChange.{AppKey}"; + + /// + /// 本地队列名模板;{AppKey} 会替换。为空时使用 oms.track_change.{AppKey}。 + /// + public string? QueueNameTemplate { get; set; } = "oms.track_change.{AppKey}"; + + /// + /// Fake.RabbitMQ 连接名;为空则使用 RabbitMqEventBusOptions.ConnectionName。 + /// + public string? ConnectionName { get; set; } + + /// 消费预取数量。 + public ushort PrefetchCount { get; set; } = 10; + + /// + /// 覆盖轨迹 AppKey(一般留空,走 Oms:TrackService / 系统其他配置 track_service)。 + /// + public string? AppKey { get; set; } + + /// 处理失败时是否重新入队。 + public bool FailedRequeue { get; set; } = true; +} diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeSubscriberHostedService.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeSubscriberHostedService.cs new file mode 100644 index 0000000..fa98843 --- /dev/null +++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeSubscriberHostedService.cs @@ -0,0 +1,202 @@ +using System.Text; +using Fake.EventBus.RabbitMQ; +using Fake.RabbitMQ; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Oms.Application.Integrations.SmallBagTrack; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Oms.Application.Messaging.TrackChange; + +/// +/// 订阅轨迹中心 TrackChange 广播,驱动 OMS 出库后订单状态变更。 +/// +public sealed class TrackChangeSubscriberHostedService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IRabbitMqConnectionPool _connectionPool; + private readonly IOptions _subscribeOptions; + private readonly IOptions _eventBusOptions; + private readonly ILogger _logger; + + private IModel? _channel; + + public TrackChangeSubscriberHostedService( + IServiceScopeFactory scopeFactory, + IRabbitMqConnectionPool connectionPool, + IOptions subscribeOptions, + IOptions eventBusOptions, + ILogger logger) + { + _scopeFactory = scopeFactory; + _connectionPool = connectionPool; + _subscribeOptions = subscribeOptions; + _eventBusOptions = eventBusOptions; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var options = _subscribeOptions.Value; + if (!options.Enabled) + { + _logger.LogInformation("TrackChange subscribe disabled"); + return; + } + + string? appKey; + try + { + appKey = await ResolveAppKeyAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "TrackChange subscribe failed to resolve AppKey; consumer not started"); + return; + } + + if (string.IsNullOrWhiteSpace(appKey)) + { + _logger.LogWarning("TrackChange subscribe skipped: AppKey empty (configure Oms:TrackService or Oms:TrackChangeSubscribe:AppKey)"); + return; + } + + var exchange = string.IsNullOrWhiteSpace(options.Exchange) + ? "track.on_track_change_ex" + : options.Exchange.Trim(); + var routingKey = (options.RoutingKeyTemplate ?? "TrackChange.{AppKey}") + .Replace("{AppKey}", appKey, StringComparison.OrdinalIgnoreCase); + var queueName = string.IsNullOrWhiteSpace(options.QueueNameTemplate) + ? $"oms.track_change.{appKey}" + : options.QueueNameTemplate.Replace("{AppKey}", appKey, StringComparison.OrdinalIgnoreCase); + + var connectionName = string.IsNullOrWhiteSpace(options.ConnectionName) + ? _eventBusOptions.Value.ConnectionName + : options.ConnectionName; + + try + { + // Connection 由 Fake 连接池托管,禁止 Dispose + var connection = _connectionPool.Get(connectionName); + _channel = connection.CreateModel(); + _channel.ExchangeDeclare(exchange, ExchangeType.Topic, durable: true, autoDelete: false); + _channel.QueueDeclare(queueName, durable: true, exclusive: false, autoDelete: false); + _channel.QueueBind(queueName, exchange, routingKey); + if (options.PrefetchCount > 0) + { + _channel.BasicQos(0, options.PrefetchCount, false); + } + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.Received += OnReceivedAsync; + _channel.BasicConsume(queue: queueName, autoAck: false, consumer: consumer); + + _logger.LogInformation( + "TrackChange subscribe started Exchange={Exchange} RoutingKey={RoutingKey} Queue={Queue}", + exchange, + routingKey, + queueName); + } + catch (Exception ex) + { + _logger.LogError(ex, "TrackChange subscribe failed to start"); + DisposeChannel(); + return; + } + + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) + { + // shutdown + } + finally + { + DisposeChannel(); + _logger.LogInformation("TrackChange subscribe stopped"); + } + } + + private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs ea) + { + var channel = _channel; + if (channel == null || !channel.IsOpen) + { + return; + } + + var options = _subscribeOptions.Value; + try + { + var json = Encoding.UTF8.GetString(ea.Body.Span); + var eto = JsonConvert.DeserializeObject(json); + if (eto == null) + { + _logger.LogWarning("TrackChange deserialize null body"); + channel.BasicAck(ea.DeliveryTag, false); + return; + } + + await using var scope = _scopeFactory.CreateAsyncScope(); + var applier = scope.ServiceProvider.GetRequiredService(); + await applier.ApplyAsync(eto); + + channel.BasicAck(ea.DeliveryTag, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "TrackChange handle failed DeliveryTag={DeliveryTag}", ea.DeliveryTag); + try + { + channel.BasicNack(ea.DeliveryTag, false, options.FailedRequeue); + } + catch (Exception nackEx) + { + _logger.LogError(nackEx, "TrackChange Nack failed DeliveryTag={DeliveryTag}", ea.DeliveryTag); + } + } + } + + private async Task ResolveAppKeyAsync(CancellationToken cancellationToken) + { + var overrideKey = _subscribeOptions.Value.AppKey?.Trim(); + if (!string.IsNullOrWhiteSpace(overrideKey)) + { + return overrideKey; + } + + await using var scope = _scopeFactory.CreateAsyncScope(); + var trackConfig = scope.ServiceProvider.GetRequiredService(); + var settings = await trackConfig.GetAsync(cancellationToken); + return string.IsNullOrWhiteSpace(settings.AppKey) ? null : settings.AppKey.Trim(); + } + + private void DisposeChannel() + { + try + { + _channel?.Close(); + _channel?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "TrackChange channel dispose ignored"); + } + finally + { + _channel = null; + } + } + + public override void Dispose() + { + DisposeChannel(); + base.Dispose(); + } +} diff --git a/src/Oms.Application/Messaging/TrackChange/TrackStatusOrderStatusMapper.cs b/src/Oms.Application/Messaging/TrackChange/TrackStatusOrderStatusMapper.cs new file mode 100644 index 0000000..89fe2cf --- /dev/null +++ b/src/Oms.Application/Messaging/TrackChange/TrackStatusOrderStatusMapper.cs @@ -0,0 +1,38 @@ +using Oms.Domain.OrdersAggregate.Consts; + +namespace Oms.Application.Messaging.TrackChange; + +/// +/// 轨迹状态码 → OMS 出库后订单状态。 +/// +public static class TrackStatusOrderStatusMapper +{ + /// 运输中。 + public const string InTransit = "D03"; + + /// 派送失败。 + public const string DeliveryFailed = "D09"; + + /// 派送成功。 + public const string DeliverySuccess = "D11"; + + /// + /// 将轨迹状态码映射为 OMS OrderStatus;未映射返回 null。 + /// + public static string? TryMapToOrderStatus(string? trackStatusCode) + { + var code = trackStatusCode?.Trim(); + if (string.IsNullOrWhiteSpace(code)) + { + return null; + } + + return code.ToUpperInvariant() switch + { + InTransit => OrderStatusCodes.Shipping, + DeliveryFailed => OrderStatusCodes.DeliveryFailed, + DeliverySuccess => OrderStatusCodes.DeliverySuccess, + _ => null + }; + } +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs index 71173e7..12fded3 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -19,6 +19,9 @@ public static class OrderOperationActionCodes public const string Outbound = "outbound"; public const string ChangePostShipStatus = "changePostShipStatus"; + /// 轨迹中心状态同步(TrackChange D03/D09/D11)。 + public const string TrackStatusSync = "trackStatusSync"; + public static string GetActionName(string action) => action switch { EditBasic => "编辑订单详情", @@ -37,6 +40,7 @@ public static class OrderOperationActionCodes Inbound => "入库", Outbound => "出库", ChangePostShipStatus => "修改出库后状态", + TrackStatusSync => "轨迹状态同步", _ => action }; } diff --git a/src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs b/src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs index c980fda..d2bc528 100644 --- a/src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs +++ b/src/Oms.E2E.Tests/Support/OrderE2eTestHost.cs @@ -52,6 +52,7 @@ internal sealed class OrderE2eTestHost : IAsyncDisposable ["Redis:Host"] = "127.0.0.1:6379", ["Redis:Database"] = "0", ["OrderBatch:RabbitMq:Enabled"] = "false", + ["Oms:TrackChangeSubscribe:Enabled"] = "false", ["Oms:TrackService:Enabled"] = "false", ["Oms:TrackService:BaseUrl"] = "http://127.0.0.1:9090", ["Oms:TrackService:AppKey"] = "E2E_KEY", diff --git a/src/Oms.Tests/Messaging/TrackStatusOrderStatusMapperTests.cs b/src/Oms.Tests/Messaging/TrackStatusOrderStatusMapperTests.cs new file mode 100644 index 0000000..32f8ecd --- /dev/null +++ b/src/Oms.Tests/Messaging/TrackStatusOrderStatusMapperTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using Oms.Application.Messaging.TrackChange; +using Oms.Domain.OrdersAggregate.Consts; + +namespace Oms.Tests.Messaging; + +public class TrackStatusOrderStatusMapperTests +{ + [Theory] + [InlineData("D03", OrderStatusCodes.Shipping)] + [InlineData("d03", OrderStatusCodes.Shipping)] + [InlineData("D09", OrderStatusCodes.DeliveryFailed)] + [InlineData("D11", OrderStatusCodes.DeliverySuccess)] + public void TryMapToOrderStatus_KnownCodes_Maps(string code, string expected) + { + TrackStatusOrderStatusMapper.TryMapToOrderStatus(code).Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("A0701")] + [InlineData("D01")] + public void TryMapToOrderStatus_Unknown_ReturnsNull(string? code) + { + TrackStatusOrderStatusMapper.TryMapToOrderStatus(code).Should().BeNull(); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs b/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs index 3002612..41b21d7 100644 --- a/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs +++ b/src/SimpleAdmin.Api.IntegrationTests/Support/SimpleAdminApiTestHost.cs @@ -76,7 +76,8 @@ internal sealed class SimpleAdminApiTestHost : IAsyncDisposable ["FeiShuNotice:EnableFeiShuExceptionSubscribe"] = "false", ["Redis:Host"] = "127.0.0.1:6379", ["Redis:Database"] = "0", - ["OrderBatch:RabbitMq:Enabled"] = "false" + ["OrderBatch:RabbitMq:Enabled"] = "false", + ["Oms:TrackChangeSubscribe:Enabled"] = "false" }); }, environmentName: "Testing"); -- 2.45.2 From 346f41c204a482f82fd322e25f59f63b12ef0586 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 27 Oct 2026 09:48:01 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E8=BF=90=E5=8D=95=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=AD=9B=E9=80=89=E8=A1=A5=E9=BD=90=E6=8B=A6=E6=88=AA?= =?UTF-8?q?/=E5=81=8F=E8=BF=9C/COD=E5=8F=8A=E5=A4=84=E7=90=86=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AD=89=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Orders/OrderRepository.cs | 127 +++++++++++++++++- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 90288bc..df1e482 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -470,15 +470,51 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I q => q.Where(x => x.AllocationPendingReason == null || x.AllocationPendingReason == string.Empty), q => q.Where(x => x.AllocationPendingReason != null && x.AllocationPendingReason != string.Empty)); } - else if (string.Equals(field, "isPaid", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(field, "isPaid", StringComparison.OrdinalIgnoreCase) + || string.Equals(field, "isIntercept", StringComparison.OrdinalIgnoreCase) + || string.Equals(field, "isRemote", StringComparison.OrdinalIgnoreCase) + || string.Equals(field, "isCod", StringComparison.OrdinalIgnoreCase)) { - if (byte.TryParse(value, out var paid) || TryParseBoolByte(value, out paid)) + query = ApplyByteFlagField(query, field, op, value, values); + } + else if (string.Equals(field, "handleStatus", StringComparison.OrdinalIgnoreCase)) + { + query = ApplyStringField(query, op, value, values, + (q, v) => q.Where(x => (x.HandleStatus ?? string.Empty).Contains(v)), + (q, v) => q.Where(x => x.HandleStatus == v), + (q, v) => q.Where(x => x.HandleStatus != v), + (q, list) => q.Where(x => x.HandleStatus != null && list.Contains(x.HandleStatus)), + (q, list) => q.Where(x => x.HandleStatus == null || !list.Contains(x.HandleStatus)), + q => q.Where(x => x.HandleStatus == null || x.HandleStatus == string.Empty), + q => q.Where(x => x.HandleStatus != null && x.HandleStatus != string.Empty)); + } + else if (string.Equals(field, "previousOrderStatus", StringComparison.OrdinalIgnoreCase)) + { + query = ApplyStringField(query, op, value, values, + (q, v) => q.Where(x => (x.PreviousOrderStatus ?? string.Empty).Contains(v)), + (q, v) => q.Where(x => x.PreviousOrderStatus == v), + (q, v) => q.Where(x => x.PreviousOrderStatus != v), + (q, list) => q.Where(x => x.PreviousOrderStatus != null && list.Contains(x.PreviousOrderStatus)), + (q, list) => q.Where(x => x.PreviousOrderStatus == null || !list.Contains(x.PreviousOrderStatus)), + q => q.Where(x => x.PreviousOrderStatus == null || x.PreviousOrderStatus == string.Empty), + q => q.Where(x => x.PreviousOrderStatus != null && x.PreviousOrderStatus != string.Empty)); + } + else if (string.Equals(field, "stockBindStatus", StringComparison.OrdinalIgnoreCase)) + { + var statusValue = values.Count == 1 ? values[0] : value; + if (!string.IsNullOrWhiteSpace(statusValue) + && (op is "eq" or "in" or "like" || string.IsNullOrWhiteSpace(op))) { - query = op switch - { - "ne" => query.Where(x => x.IsPaid != paid), - _ => query.Where(x => x.IsPaid == paid) - }; + query = ApplyStockBindStatusFilter(query, statusValue); + } + } + else if (string.Equals(field, "fulfillProgress", StringComparison.OrdinalIgnoreCase)) + { + var progressValue = values.Count == 1 ? values[0] : value; + if (!string.IsNullOrWhiteSpace(progressValue) + && (op is "eq" or "in" or "like" || string.IsNullOrWhiteSpace(op))) + { + query = ApplyFulfillProgressFilter(query, progressValue); } } else if (string.Equals(field, "payableAmount", StringComparison.OrdinalIgnoreCase)) @@ -557,6 +593,83 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I return false; } + private static List ResolveBoolBytes(string value, List values) + { + var raw = values.Count > 0 + ? values + : string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + var flags = new List(); + foreach (var item in raw) + { + if (byte.TryParse(item, out var flag) || TryParseBoolByte(item, out flag)) + { + flags.Add(flag); + } + } + + return flags.Distinct().ToList(); + } + + /// + /// 字节布尔标志筛选(0/1),支持 eq / ne / in / notIn。 + /// + private static ISugarQueryable ApplyByteFlagField( + ISugarQueryable query, + string field, + string op, + string value, + List values) + { + var flags = ResolveBoolBytes(value, values); + if (flags.Count == 0) + { + return query; + } + + var negate = op is "ne" or "notIn"; + var multi = op is "in" or "notIn" || flags.Count > 1; + if (string.Equals(field, "isPaid", StringComparison.OrdinalIgnoreCase)) + { + return negate + ? query.Where(x => !flags.Contains(x.IsPaid)) + : multi + ? query.Where(x => flags.Contains(x.IsPaid)) + : query.Where(x => x.IsPaid == flags[0]); + } + + if (string.Equals(field, "isIntercept", StringComparison.OrdinalIgnoreCase)) + { + return negate + ? query.Where(x => !flags.Contains(x.IsIntercept)) + : multi + ? query.Where(x => flags.Contains(x.IsIntercept)) + : query.Where(x => x.IsIntercept == flags[0]); + } + + if (string.Equals(field, "isRemote", StringComparison.OrdinalIgnoreCase)) + { + return negate + ? query.Where(x => !flags.Contains(x.IsRemote)) + : multi + ? query.Where(x => flags.Contains(x.IsRemote)) + : query.Where(x => x.IsRemote == flags[0]); + } + + if (string.Equals(field, "isCod", StringComparison.OrdinalIgnoreCase)) + { + return negate + ? query.Where(x => !flags.Contains(x.IsCod)) + : multi + ? query.Where(x => flags.Contains(x.IsCod)) + : query.Where(x => x.IsCod == flags[0]); + } + + return query; + } + private static ISugarQueryable ApplyStringField( ISugarQueryable query, string op, -- 2.45.2