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)
{