diff --git a/db.sql b/db.sql index 24f7aeb..2f18829 100644 --- a/db.sql +++ b/db.sql @@ -595,7 +595,8 @@ CREATE TABLE `t_sales_product_allocation_provider` ( `ProviderName` VARCHAR(128) NOT NULL COMMENT '服务商名称', `ServiceChannelId` INT NULL COMMENT '服务渠道ID', `Weight` INT NOT NULL COMMENT '权重(评分用)', - `CustomerIds` JSON NULL COMMENT '指定客户ID数组,空表示全部', + `CustomerIds` JSON NULL COMMENT '指定客户ID数组,空表示全部(兼容旧配置)', + `MatchCondition` TEXT NULL COMMENT '排货判断条件(中文公式),空表示全部订单可用', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', diff --git a/sql/migrate_allocation_match_condition.sql b/sql/migrate_allocation_match_condition.sql new file mode 100644 index 0000000..274f2e0 --- /dev/null +++ b/sql/migrate_allocation_match_condition.sql @@ -0,0 +1,21 @@ +-- 存量库:排货规则服务商表增加判断条件 MatchCondition +-- 可重复执行;仅在列不存在时 ADD。 +-- 目标表:t_sales_product_allocation_provider + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_sales_product_allocation_provider' + AND COLUMN_NAME = 'MatchCondition' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_sales_product_allocation_provider` ADD COLUMN `MatchCondition` TEXT NULL COMMENT ''排货判断条件(中文公式),空表示全部订单可用'' AFTER `CustomerIds`', + 'SELECT ''MatchCondition already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderInput.cs index a157c30..b0fa3b5 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderInput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderInput.cs @@ -31,7 +31,12 @@ public class SalesProductAllocationProviderInput public int Weight { get; set; } /// - /// 客户主键集合。 + /// 客户主键集合(兼容旧配置;新配置请用 MatchCondition)。 /// public List CustomerIds { get; set; } = []; + + /// + /// 排货判断条件(中文公式);空表示全部订单可用。 + /// + public string? MatchCondition { get; set; } } diff --git a/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderOutput.cs index f469512..84b8116 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderOutput.cs @@ -41,10 +41,15 @@ public class SalesProductAllocationProviderOutput public int Weight { get; set; } /// - /// 客户主键集合。 + /// 客户主键集合(兼容旧配置)。 /// public List CustomerIds { get; set; } = []; + /// + /// 排货判断条件(中文公式);空表示全部订单可用。 + /// + public string? MatchCondition { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs index 9793fa9..abbdfa8 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs @@ -41,10 +41,15 @@ public class SalesProductAllocationProviderSnapshotDto public int Weight { get; set; } /// - /// 指定客户主键集合,空表示全部。 + /// 指定客户主键集合,空表示全部(兼容旧快照)。 /// public List CustomerIds { get; set; } = []; + /// + /// 排货判断条件(中文公式);空表示全部订单可用。 + /// + public string? MatchCondition { get; set; } + /// /// 服务渠道编码(发布快照固化)。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs b/src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs index e431cc8..438e3cb 100644 --- a/src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs +++ b/src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs @@ -36,6 +36,15 @@ public class OrderAllocationTraceOutput public DateTime? TraceTime { get; set; } + /// 排货池行数。 + public int PoolCount { get; set; } + + /// 判断条件通过的行数。 + public int ConditionPassCount { get; set; } + + /// 最终进入比选的候选数。 + public int CandidateCount { get; set; } + public List Candidates { get; set; } = []; public List Skipped { get; set; } = []; @@ -71,6 +80,12 @@ public class OrderAllocationSkippedOutput public string? ChannelName { get; set; } + /// 跳过阶段:condition / channel / buyPrice / customer。 + public string? Stage { get; set; } + + /// 当时评价的判断条件摘要(截断)。 + public string? MatchCondition { get; set; } + public string Reason { get; set; } = string.Empty; } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs index e8ff699..28ca2c5 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.BillingSimulate.cs @@ -326,7 +326,18 @@ public partial class SalesProductService : (await GetAllocationCoreAsync(product.Id)).Providers .Select(PublishSnapshotSlimmer.SlimAllocationProvider) .ToList(); - var filtered = AllocationResolver.FilterProviders(providers, customerId: null); + var filtered = AllocationResolver.FilterProvidersDetailed( + providers, + customerId: null, + OrderRuleEvaluationContext.FromOrderOnly(new Order + { + ProductCode = product.ProductCode, + CargoAttribute = cargoAttribute, + WarehouseId = warehouseId, + DeclaredSum = declaredSum, + DeclaredCurrency = declaredCurrency, + IsRemote = isRemote ? (byte)1 : (byte)0 + })).Passed; var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0); if (provider?.ServiceChannelId is not > 0) { diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index ecf7284..344f1ab 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -123,10 +123,11 @@ public partial class SalesProductService ProviderName = provider.ProviderName, ServiceChannelId = provider.ServiceChannelId, ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null, - Weight = provider.Weight, - CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [], - CreateTime = provider.CreateTime, - UpdateTime = OmsDocHelper.NullIfDefault(provider.UpdateTime) + Weight = provider.Weight, + CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [], + MatchCondition = provider.MatchCondition, + CreateTime = provider.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(provider.UpdateTime) }).ToList() }; } @@ -319,7 +320,8 @@ public partial class SalesProductService ProviderName = provider.ProviderName, ServiceChannelId = provider.ServiceChannelId, Weight = provider.Weight, - CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds) + CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds), + MatchCondition = provider.MatchCondition }).ToList(); await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers); } diff --git a/src/Oms.Application/Services/Channels/SalesProductService.cs b/src/Oms.Application/Services/Channels/SalesProductService.cs index ff28367..3b86f15 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.cs @@ -311,7 +311,8 @@ public partial class SalesProductService : ApplicationService ProviderName = provider.ProviderName, ServiceChannelId = provider.ServiceChannelId, Weight = provider.Weight, - CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds) + CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds), + MatchCondition = provider.MatchCondition }).ToList(); await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers); diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 7e2245d..88c1430 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -506,7 +506,11 @@ public partial class OrderService var binding = await ResolveSnapshotBindingAsync(product, DateTime.Now); var providers = await ResolveAllocationProvidersAsync(product, binding.Rules); - var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId); + var ruleContext = await OrderAutoTriggerService.BuildPostCommitContextAsync(order); + var filtered = AllocationResolver.FilterProvidersDetailed( + providers, + order.CustomerId, + ruleContext).Passed; var channelIds = new HashSet(); foreach (var provider in filtered) { diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 10f9c5d..08b78c0 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -861,6 +861,8 @@ public partial class OrderService public IReadOnlyList ZoneKeys { get; init; } = []; public List Candidates { get; init; } = []; public List Skipped { get; init; } = []; + public int PoolCount { get; init; } + public int ConditionPassCount { get; init; } public AllocationResolver.ChannelCandidate? Selected { get; set; } public string? FailReason { get; set; } } @@ -875,10 +877,18 @@ public partial class OrderService string? schemeOverride, int? forceServiceChannelId) { - var skipped = new List(); var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); - var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId); + // 判断条件需要客户名、件重尺等,与自动触发共用完整上下文 + var ruleContext = await OrderAutoTriggerService.BuildPostCommitContextAsync(order); + var filterResult = AllocationResolver.FilterProvidersDetailed( + providers, + order.CustomerId, + ruleContext); + var skipped = filterResult.Skipped.ToList(); + var filtered = filterResult.Passed; + var poolCount = filterResult.PoolCount; + var conditionPassCount = filterResult.ConditionPassCount; var productScheme = snapshotBinding.Rules.Product?.Scheme ?? product.Scheme @@ -900,52 +910,68 @@ public partial class OrderService ? AllocationSchemeCodes.CostLowest : storedScheme; + AllocationEvaluation BaseEval(string? failReason = null) => new() + { + StoredScheme = storedScheme, + SelectScheme = selectScheme, + SchemeSource = schemeSource, + ChargeableWeight = chargeableWeight, + ChargingStage = chargingStage, + ZoneKeys = zoneKeys, + Skipped = skipped, + PoolCount = poolCount, + ConditionPassCount = conditionPassCount, + FailReason = failReason + }; + if (forceServiceChannelId is > 0) { - var before = filtered; - filtered = filtered + var designated = filtered .Where(x => x.ServiceChannelId == forceServiceChannelId.Value) .ToList(); - if (filtered.Count == 0) + if (designated.Count == 0) { - foreach (var p in before.Where(x => x.ServiceChannelId != forceServiceChannelId)) + var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value); + var failReason = poolRow == null + ? "指定渠道不在该销售产品排货池" + : "指定渠道不满足判断条件"; + if (poolRow != null + && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value)) { - // 非指定渠道不记跳过;指定不在池内单独失败 + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = forceServiceChannelId.Value, + ChannelCode = poolRow.ChannelCode, + ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName, + Stage = AllocationResolver.SkipStageCondition, + MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition), + Reason = failReason + }); } - return new AllocationEvaluation - { - StoredScheme = storedScheme, - SelectScheme = selectScheme, - SchemeSource = schemeSource, - ChargeableWeight = chargeableWeight, - ChargingStage = chargingStage, - ZoneKeys = zoneKeys, - Skipped = skipped, - FailReason = "指定渠道不在该销售产品排货池或当前客户不可用" - }; + return BaseEval(failReason); } + + filtered = designated; } if (filtered.Count == 0) { - return new AllocationEvaluation - { - StoredScheme = storedScheme, - SelectScheme = selectScheme, - SchemeSource = schemeSource, - ChargeableWeight = chargeableWeight, - ChargingStage = chargingStage, - ZoneKeys = zoneKeys, - Skipped = skipped, - FailReason = "排货规则未匹配到可用服务渠道" - }; + var conditionMiss = skipped.Count(x => + x.Stage is AllocationResolver.SkipStageCondition or AllocationResolver.SkipStageCustomer); + var failReason = poolCount == 0 + ? "排货规则未配置可用服务渠道" + : conditionMiss > 0 + ? $"排货规则未匹配到可用服务渠道(条件未命中 {conditionMiss}/{poolCount})" + : "排货规则未匹配到可用服务渠道"; + return BaseEval(failReason); } // 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。 var bindTime = DateTime.Now; var candidates = new List(); - var recipient = (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault(); + var recipient = ruleContext.Recipient + ?? (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault(); var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General; foreach (var provider in filtered) @@ -957,6 +983,8 @@ public partial class OrderService skipped.Add(new OrderAllocationSkippedOutput { ServiceChannelId = channelId, + Stage = AllocationResolver.SkipStageChannel, + MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), Reason = "服务渠道不存在" }); continue; @@ -969,6 +997,8 @@ public partial class OrderService ServiceChannelId = channel.Id, ChannelCode = channel.ChannelCode, ChannelName = channel.ChannelName, + Stage = AllocationResolver.SkipStageChannel, + MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), Reason = "服务渠道未启用" }); continue; @@ -1017,6 +1047,8 @@ public partial class OrderService ServiceChannelId = channel.Id, ChannelCode = channel.ChannelCode, ChannelName = channel.ChannelName, + Stage = AllocationResolver.SkipStageBuyPrice, + MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), Reason = string.IsNullOrWhiteSpace(miss) ? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})" : $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}" @@ -1035,7 +1067,17 @@ public partial class OrderService if (candidates.Count == 0) { - var detail = skipped.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Reason))?.Reason; + var buyPriceMiss = skipped.Count(x => x.Stage == AllocationResolver.SkipStageBuyPrice); + var detail = skipped.FirstOrDefault(x => + x.Stage == AllocationResolver.SkipStageBuyPrice + && !string.IsNullOrWhiteSpace(x.Reason))?.Reason; + var failReason = chargeableWeight <= 0 + ? "计费重无效,无法计算应付费用" + : buyPriceMiss > 0 + ? (string.IsNullOrWhiteSpace(detail) + ? $"排货规则未匹配到有效渠道买价(买价未命中 {buyPriceMiss})" + : $"排货规则未匹配到有效渠道买价;{detail}") + : "排货规则未匹配到有效渠道买价"; return new AllocationEvaluation { StoredScheme = storedScheme, @@ -1046,11 +1088,9 @@ public partial class OrderService ZoneKeys = zoneKeys, Candidates = candidates, Skipped = skipped, - FailReason = chargeableWeight <= 0 - ? "计费重无效,无法计算应付费用" - : string.IsNullOrWhiteSpace(detail) - ? "排货规则未匹配到有效渠道买价" - : $"排货规则未匹配到有效渠道买价;{detail}" + PoolCount = poolCount, + ConditionPassCount = conditionPassCount, + FailReason = failReason }; } @@ -1065,6 +1105,8 @@ public partial class OrderService ZoneKeys = zoneKeys, Candidates = candidates, Skipped = skipped, + PoolCount = poolCount, + ConditionPassCount = conditionPassCount, Selected = selected }; } @@ -1127,6 +1169,9 @@ public partial class OrderService SelectedChannelName = evaluation.Selected?.Channel.ChannelName, FailReason = evaluation.FailReason, TraceTime = DateTime.Now, + PoolCount = evaluation.PoolCount, + ConditionPassCount = evaluation.ConditionPassCount, + CandidateCount = candidates.Count, Candidates = candidates, Skipped = evaluation.Skipped }; @@ -1255,7 +1300,8 @@ public partial class OrderService ServiceChannelId = provider.ServiceChannelId, ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null, Weight = provider.Weight, - CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [] + CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [], + MatchCondition = provider.MatchCondition })).ToList(); await AllocationProviderSnapshotEnricher.EnrichAsync(providers); } diff --git a/src/Oms.Application/Shared/AllocationResolver.cs b/src/Oms.Application/Shared/AllocationResolver.cs index fe9929c..80c8e54 100644 --- a/src/Oms.Application/Shared/AllocationResolver.cs +++ b/src/Oms.Application/Shared/AllocationResolver.cs @@ -1,4 +1,6 @@ using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders.Issues; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.OrdersAggregate.Entities; @@ -10,6 +12,13 @@ namespace Oms.Application.Shared; /// public static class AllocationResolver { + public const string SkipStageCondition = "condition"; + public const string SkipStageCustomer = "customer"; + public const string SkipStageChannel = "channel"; + public const string SkipStageBuyPrice = "buyPrice"; + + private const int MatchConditionTraceMaxLen = 200; + public sealed class ChannelCandidate { public SalesProductAllocationProviderSnapshotDto Provider { get; init; } = null!; @@ -22,16 +31,140 @@ public static class AllocationResolver public IReadOnlyList PayableFees { get; init; } = []; } + public sealed class FilterProvidersResult + { + public List Passed { get; init; } = []; + + public List Skipped { get; init; } = []; + + public int PoolCount { get; init; } + + public int ConditionPassCount { get; init; } + } + + /// + /// 兼容旧调用:仅按客户名单过滤(无判断条件上下文时)。 + /// public static List FilterProviders( IEnumerable providers, int? customerId) => - providers + FilterProvidersDetailed(providers, customerId, context: null).Passed; + + /// + /// 先按判断条件(或兼容客户名单)筛出可用渠道;空条件恒真。 + /// + public static FilterProvidersResult FilterProvidersDetailed( + IEnumerable providers, + int? customerId, + OrderRuleEvaluationContext? context) + { + var pool = providers .Where(x => x.ServiceChannelId.HasValue && x.ServiceChannelId > 0) - .Where(x => x.CustomerIds.Count == 0 - || (customerId.HasValue && x.CustomerIds.Contains(customerId.Value))) .OrderBy(x => x.SerialNumber) .ToList(); + var passed = new List(); + var skipped = new List(); + + foreach (var provider in pool) + { + var match = EvaluateProviderMatch(provider, customerId, context); + if (match.Pass) + { + passed.Add(provider); + continue; + } + + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = provider.ServiceChannelId, + ChannelCode = provider.ChannelCode, + ChannelName = provider.ServiceChannelName ?? provider.ProviderName, + Stage = match.Stage, + MatchCondition = TruncateMatchCondition(provider.MatchCondition), + Reason = match.Reason + }); + } + + // 同一渠道多行命中时保留权重更高、序号更小的一行 + var deduped = DeduplicateByChannel(passed); + + return new FilterProvidersResult + { + Passed = deduped, + Skipped = skipped, + PoolCount = pool.Count, + ConditionPassCount = passed.Count + }; + } + + private static List DeduplicateByChannel( + List passed) + { + return passed + .GroupBy(x => x.ServiceChannelId!.Value) + .Select(g => g + .OrderByDescending(x => x.Weight) + .ThenBy(x => x.SerialNumber) + .First()) + .OrderBy(x => x.SerialNumber) + .ToList(); + } + + private readonly record struct MatchOutcome(bool Pass, string Stage, string Reason); + + private static MatchOutcome EvaluateProviderMatch( + SalesProductAllocationProviderSnapshotDto provider, + int? customerId, + OrderRuleEvaluationContext? context) + { + var formula = provider.MatchCondition?.Trim(); + if (!string.IsNullOrWhiteSpace(formula) + && !string.Equals(formula, "无", StringComparison.OrdinalIgnoreCase)) + { + if (context == null) + { + return new MatchOutcome(false, SkipStageCondition, "判断条件缺少订单上下文"); + } + + if (!ChineseFormulaEvaluator.TryEvaluate(formula, context, out var hit)) + { + return new MatchOutcome(false, SkipStageCondition, "判断条件公式无效"); + } + + if (!hit) + { + return new MatchOutcome(false, SkipStageCondition, "判断条件未命中"); + } + + return new MatchOutcome(true, SkipStageCondition, string.Empty); + } + + // 无公式:兼容旧 CustomerIds 名单 + if (provider.CustomerIds.Count > 0) + { + if (customerId.HasValue && provider.CustomerIds.Contains(customerId.Value)) + { + return new MatchOutcome(true, SkipStageCustomer, string.Empty); + } + + return new MatchOutcome(false, SkipStageCustomer, "客户不在名单内"); + } + + return new MatchOutcome(true, SkipStageCondition, string.Empty); + } + + public static string TruncateMatchCondition(string? condition) + { + var text = condition?.Trim() ?? string.Empty; + if (text.Length <= MatchConditionTraceMaxLen) + { + return text; + } + + return text[..MatchConditionTraceMaxLen] + "…"; + } + public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList candidates) => SelectCandidate(scheme, candidates).Channel; diff --git a/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs b/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs index bfb2826..8476e8f 100644 --- a/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs +++ b/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs @@ -67,7 +67,8 @@ public static class PublishSnapshotSlimmer ServiceChannelId = provider.ServiceChannelId, ServiceChannelName = provider.ServiceChannelName, Weight = provider.Weight, - CustomerIds = provider.CustomerIds + CustomerIds = provider.CustomerIds, + MatchCondition = provider.MatchCondition }; public static WeightRuleSnapshotRuleDto SlimWeightRule(WeightRuleInfo entity) => diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProductAllocationProvider.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProductAllocationProvider.cs index 677ce5c..77481cc 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProductAllocationProvider.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProductAllocationProvider.cs @@ -18,4 +18,10 @@ public class SalesProductAllocationProvider : OmsAuditedEntity [SugarColumn(ColumnDataType = "json", IsNullable = true)] public string? CustomerIds { get; set; } + + /// + /// 排货判断条件(中文公式);空表示全部订单可用。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? MatchCondition { get; set; } } diff --git a/src/Oms.Tests/AllocationResolverTests.cs b/src/Oms.Tests/AllocationResolverTests.cs index 44c66b6..5cba51b 100644 --- a/src/Oms.Tests/AllocationResolverTests.cs +++ b/src/Oms.Tests/AllocationResolverTests.cs @@ -1,8 +1,11 @@ using FluentAssertions; using Oms.Application.Dtos.Channels; using Oms.Application.Shared; +using Oms.Application.Services.Orders.Issues; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.CustomersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Entities; namespace Oms.Tests; @@ -23,6 +26,159 @@ public class AllocationResolverTests filtered.Select(x => x.ServiceChannelId).Should().BeEquivalentTo([1, 2]); } + [Fact] + public void FilterProvidersDetailed_EmptyCondition_ShouldPass() + { + var providers = new List + { + new() { SerialNumber = 1, ServiceChannelId = 1, MatchCondition = null }, + new() { SerialNumber = 2, ServiceChannelId = 2, MatchCondition = " " } + }; + + var result = AllocationResolver.FilterProvidersDetailed(providers, 1, Context()); + result.Passed.Should().HaveCount(2); + result.Skipped.Should().BeEmpty(); + result.PoolCount.Should().Be(2); + result.ConditionPassCount.Should().Be(2); + } + + [Fact] + public void FilterProvidersDetailed_CargoAttribute_ShouldFilter() + { + var providers = new List + { + new() + { + SerialNumber = 1, + ServiceChannelId = 1, + ChannelCode = "SENS", + MatchCondition = "货物属性包含('sensitive')" + }, + new() + { + SerialNumber = 2, + ServiceChannelId = 2, + ChannelCode = "GEN", + MatchCondition = "货物属性包含('general')" + } + }; + + var result = AllocationResolver.FilterProvidersDetailed( + providers, + customerId: null, + Context(cargoAttribute: "sensitive")); + + result.Passed.Should().HaveCount(1); + result.Passed[0].ServiceChannelId.Should().Be(1); + result.Skipped.Should().HaveCount(1); + result.Skipped[0].Stage.Should().Be(AllocationResolver.SkipStageCondition); + result.Skipped[0].Reason.Should().Be("判断条件未命中"); + result.Skipped[0].MatchCondition.Should().Contain("货物属性包含"); + } + + [Fact] + public void FilterProvidersDetailed_CustomerContains_ShouldUseCustomerEntity() + { + var providers = new List + { + new() + { + SerialNumber = 1, + ServiceChannelId = 1, + MatchCondition = "客户包含('阿里')" + }, + new() + { + SerialNumber = 2, + ServiceChannelId = 2, + MatchCondition = "客户包含('京东')" + } + }; + + var result = AllocationResolver.FilterProvidersDetailed( + providers, + customerId: 9, + Context(customerName: "阿里国际站")); + + result.Passed.Should().HaveCount(1); + result.Passed[0].ServiceChannelId.Should().Be(1); + result.Skipped[0].Reason.Should().Be("判断条件未命中"); + } + + [Fact] + public void FilterProvidersDetailed_InvalidFormula_ShouldSkip() + { + var providers = new List + { + new() + { + SerialNumber = 1, + ServiceChannelId = 1, + MatchCondition = "实重()>" + } + }; + + var result = AllocationResolver.FilterProvidersDetailed(providers, null, Context()); + result.Passed.Should().BeEmpty(); + result.Skipped.Should().HaveCount(1); + result.Skipped[0].Reason.Should().Be("判断条件公式无效"); + result.PoolCount.Should().Be(1); + result.ConditionPassCount.Should().Be(0); + } + + [Fact] + public void FilterProvidersDetailed_LegacyCustomerIds_WhenNoFormula() + { + var providers = new List + { + new() { SerialNumber = 1, ServiceChannelId = 1, CustomerIds = [10] }, + new() { SerialNumber = 2, ServiceChannelId = 2, CustomerIds = [99] } + }; + + var result = AllocationResolver.FilterProvidersDetailed(providers, 10, Context()); + result.Passed.Should().HaveCount(1); + result.Passed[0].ServiceChannelId.Should().Be(1); + result.Skipped[0].Stage.Should().Be(AllocationResolver.SkipStageCustomer); + result.Skipped[0].Reason.Should().Be("客户不在名单内"); + } + + [Fact] + public void FilterProvidersDetailed_MatchCondition_IgnoresLegacyCustomerIds() + { + var providers = new List + { + new() + { + SerialNumber = 1, + ServiceChannelId = 1, + CustomerIds = [99], + MatchCondition = "货物属性包含('general')" + } + }; + + var result = AllocationResolver.FilterProvidersDetailed( + providers, + customerId: 1, + Context(cargoAttribute: "general")); + + result.Passed.Should().HaveCount(1); + } + + [Fact] + public void FilterProvidersDetailed_DeduplicateSameChannel_KeepsHigherWeight() + { + var providers = new List + { + new() { SerialNumber = 1, ServiceChannelId = 7, Weight = 10, MatchCondition = "" }, + new() { SerialNumber = 2, ServiceChannelId = 7, Weight = 30, MatchCondition = "" } + }; + + var result = AllocationResolver.FilterProvidersDetailed(providers, null, Context()); + result.ConditionPassCount.Should().Be(2); + result.Passed.Should().HaveCount(1); + result.Passed[0].Weight.Should().Be(30); + } + [Fact] public void SelectChannel_CostLowest_ShouldPickCheapest() { @@ -56,6 +212,20 @@ public class AllocationResolverTests channel.Id.Should().Be(2); } + private static OrderRuleEvaluationContext Context( + string cargoAttribute = "general", + string? customerName = null) => + OrderRuleEvaluationContext.FromOrder( + new Order { CargoAttribute = cargoAttribute }, + string.IsNullOrWhiteSpace(customerName) + ? null + : new Customer { CustomerName = customerName }, + null, + null, + null, + null, + null); + private static List BuildCandidates( params (int channelId, decimal cost, int minTime, int weight)[] rows) {