feat: 排货规则支持判断条件公式并完善失败溯源
All checks were successful
Build and Deploy / deploy (push) Successful in 1m49s

用 MatchCondition 中文公式筛选可用渠道后再按方案选渠;失败时落完整排货过程(阶段、公式摘要、池汇总),并兼容旧 CustomerIds。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-17 19:43:05 +08:00
parent 6b920cc49d
commit e5c9364434
15 changed files with 479 additions and 53 deletions

3
db.sql
View File

@ -595,7 +595,8 @@ CREATE TABLE `t_sales_product_allocation_provider` (
`ProviderName` VARCHAR(128) NOT NULL COMMENT '服务商名称', `ProviderName` VARCHAR(128) NOT NULL COMMENT '服务商名称',
`ServiceChannelId` INT NULL COMMENT '服务渠道ID', `ServiceChannelId` INT NULL COMMENT '服务渠道ID',
`Weight` INT NOT NULL COMMENT '权重(评分用)', `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-是', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

View File

@ -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;

View File

@ -31,7 +31,12 @@ public class SalesProductAllocationProviderInput
public int Weight { get; set; } public int Weight { get; set; }
/// <summary> /// <summary>
/// 客户主键集合。 /// 客户主键集合(兼容旧配置;新配置请用 MatchCondition)。
/// </summary> /// </summary>
public List<int> CustomerIds { get; set; } = []; public List<int> CustomerIds { get; set; } = [];
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
public string? MatchCondition { get; set; }
} }

View File

@ -41,10 +41,15 @@ public class SalesProductAllocationProviderOutput
public int Weight { get; set; } public int Weight { get; set; }
/// <summary> /// <summary>
/// 客户主键集合。 /// 客户主键集合(兼容旧配置)。
/// </summary> /// </summary>
public List<int> CustomerIds { get; set; } = []; public List<int> CustomerIds { get; set; } = [];
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
public string? MatchCondition { get; set; }
/// <summary> /// <summary>
/// 创建时间。 /// 创建时间。
/// </summary> /// </summary>

View File

@ -41,10 +41,15 @@ public class SalesProductAllocationProviderSnapshotDto
public int Weight { get; set; } public int Weight { get; set; }
/// <summary> /// <summary>
/// 指定客户主键集合,空表示全部。 /// 指定客户主键集合,空表示全部(兼容旧快照)。
/// </summary> /// </summary>
public List<int> CustomerIds { get; set; } = []; public List<int> CustomerIds { get; set; } = [];
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
public string? MatchCondition { get; set; }
/// <summary> /// <summary>
/// 服务渠道编码(发布快照固化)。 /// 服务渠道编码(发布快照固化)。
/// </summary> /// </summary>

View File

@ -36,6 +36,15 @@ public class OrderAllocationTraceOutput
public DateTime? TraceTime { get; set; } public DateTime? TraceTime { get; set; }
/// <summary>排货池行数。</summary>
public int PoolCount { get; set; }
/// <summary>判断条件通过的行数。</summary>
public int ConditionPassCount { get; set; }
/// <summary>最终进入比选的候选数。</summary>
public int CandidateCount { get; set; }
public List<OrderAllocationCandidateOutput> Candidates { get; set; } = []; public List<OrderAllocationCandidateOutput> Candidates { get; set; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; set; } = []; public List<OrderAllocationSkippedOutput> Skipped { get; set; } = [];
@ -71,6 +80,12 @@ public class OrderAllocationSkippedOutput
public string? ChannelName { get; set; } public string? ChannelName { get; set; }
/// <summary>跳过阶段:condition / channel / buyPrice / customer。</summary>
public string? Stage { get; set; }
/// <summary>当时评价的判断条件摘要(截断)。</summary>
public string? MatchCondition { get; set; }
public string Reason { get; set; } = string.Empty; public string Reason { get; set; } = string.Empty;
} }

View File

@ -326,7 +326,18 @@ public partial class SalesProductService
: (await GetAllocationCoreAsync(product.Id)).Providers : (await GetAllocationCoreAsync(product.Id)).Providers
.Select(PublishSnapshotSlimmer.SlimAllocationProvider) .Select(PublishSnapshotSlimmer.SlimAllocationProvider)
.ToList(); .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); var provider = filtered.OrderBy(x => x.SerialNumber).FirstOrDefault(x => x.ServiceChannelId is > 0);
if (provider?.ServiceChannelId is not > 0) if (provider?.ServiceChannelId is not > 0)
{ {

View File

@ -125,6 +125,7 @@ public partial class SalesProductService
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null, ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
Weight = provider.Weight, Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [], CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
MatchCondition = provider.MatchCondition,
CreateTime = provider.CreateTime, CreateTime = provider.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(provider.UpdateTime) UpdateTime = OmsDocHelper.NullIfDefault(provider.UpdateTime)
}).ToList() }).ToList()
@ -319,7 +320,8 @@ public partial class SalesProductService
ProviderName = provider.ProviderName, ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId, ServiceChannelId = provider.ServiceChannelId,
Weight = provider.Weight, Weight = provider.Weight,
CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds) CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds),
MatchCondition = provider.MatchCondition
}).ToList(); }).ToList();
await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers); await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers);
} }

View File

@ -311,7 +311,8 @@ public partial class SalesProductService : ApplicationService
ProviderName = provider.ProviderName, ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId, ServiceChannelId = provider.ServiceChannelId,
Weight = provider.Weight, Weight = provider.Weight,
CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds) CustomerIds = OmsDocHelper.SerializeJson(provider.CustomerIds),
MatchCondition = provider.MatchCondition
}).ToList(); }).ToList();
await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers); await SalesProductRepository.ReplaceAllocationProvidersAsync(input.SalesProductId, providers);

View File

@ -506,7 +506,11 @@ public partial class OrderService
var binding = await ResolveSnapshotBindingAsync(product, DateTime.Now); var binding = await ResolveSnapshotBindingAsync(product, DateTime.Now);
var providers = await ResolveAllocationProvidersAsync(product, binding.Rules); 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<int>(); var channelIds = new HashSet<int>();
foreach (var provider in filtered) foreach (var provider in filtered)
{ {

View File

@ -861,6 +861,8 @@ public partial class OrderService
public IReadOnlyList<string> ZoneKeys { get; init; } = []; public IReadOnlyList<string> ZoneKeys { get; init; } = [];
public List<AllocationResolver.ChannelCandidate> Candidates { get; init; } = []; public List<AllocationResolver.ChannelCandidate> Candidates { get; init; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = []; public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
public int PoolCount { get; init; }
public int ConditionPassCount { get; init; }
public AllocationResolver.ChannelCandidate? Selected { get; set; } public AllocationResolver.ChannelCandidate? Selected { get; set; }
public string? FailReason { get; set; } public string? FailReason { get; set; }
} }
@ -875,10 +877,18 @@ public partial class OrderService
string? schemeOverride, string? schemeOverride,
int? forceServiceChannelId) int? forceServiceChannelId)
{ {
var skipped = new List<OrderAllocationSkippedOutput>();
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); 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 var productScheme = snapshotBinding.Rules.Product?.Scheme
?? product.Scheme ?? product.Scheme
@ -900,52 +910,68 @@ public partial class OrderService
? AllocationSchemeCodes.CostLowest ? AllocationSchemeCodes.CostLowest
: storedScheme; : 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) if (forceServiceChannelId is > 0)
{ {
var before = filtered; var designated = filtered
filtered = filtered
.Where(x => x.ServiceChannelId == forceServiceChannelId.Value) .Where(x => x.ServiceChannelId == forceServiceChannelId.Value)
.ToList(); .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 return BaseEval(failReason);
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
FailReason = "指定渠道不在该销售产品排货池或当前客户不可用"
};
} }
filtered = designated;
} }
if (filtered.Count == 0) if (filtered.Count == 0)
{ {
return new AllocationEvaluation var conditionMiss = skipped.Count(x =>
{ x.Stage is AllocationResolver.SkipStageCondition or AllocationResolver.SkipStageCustomer);
StoredScheme = storedScheme, var failReason = poolCount == 0
SelectScheme = selectScheme, ? "排货规则未配置可用服务渠道"
SchemeSource = schemeSource, : conditionMiss > 0
ChargeableWeight = chargeableWeight, ? $"排货规则未匹配到可用服务渠道(条件未命中 {conditionMiss}/{poolCount})"
ChargingStage = chargingStage, : "排货规则未匹配到可用服务渠道";
ZoneKeys = zoneKeys, return BaseEval(failReason);
Skipped = skipped,
FailReason = "排货规则未匹配到可用服务渠道"
};
} }
// 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。 // 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。
var bindTime = DateTime.Now; var bindTime = DateTime.Now;
var candidates = new List<AllocationResolver.ChannelCandidate>(); var candidates = new List<AllocationResolver.ChannelCandidate>();
var recipient = (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault(); var recipient = ruleContext.Recipient
?? (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault();
var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General; var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General;
foreach (var provider in filtered) foreach (var provider in filtered)
@ -957,6 +983,8 @@ public partial class OrderService
skipped.Add(new OrderAllocationSkippedOutput skipped.Add(new OrderAllocationSkippedOutput
{ {
ServiceChannelId = channelId, ServiceChannelId = channelId,
Stage = AllocationResolver.SkipStageChannel,
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
Reason = "服务渠道不存在" Reason = "服务渠道不存在"
}); });
continue; continue;
@ -969,6 +997,8 @@ public partial class OrderService
ServiceChannelId = channel.Id, ServiceChannelId = channel.Id,
ChannelCode = channel.ChannelCode, ChannelCode = channel.ChannelCode,
ChannelName = channel.ChannelName, ChannelName = channel.ChannelName,
Stage = AllocationResolver.SkipStageChannel,
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
Reason = "服务渠道未启用" Reason = "服务渠道未启用"
}); });
continue; continue;
@ -1017,6 +1047,8 @@ public partial class OrderService
ServiceChannelId = channel.Id, ServiceChannelId = channel.Id,
ChannelCode = channel.ChannelCode, ChannelCode = channel.ChannelCode,
ChannelName = channel.ChannelName, ChannelName = channel.ChannelName,
Stage = AllocationResolver.SkipStageBuyPrice,
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
Reason = string.IsNullOrWhiteSpace(miss) Reason = string.IsNullOrWhiteSpace(miss)
? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})" ? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})"
: $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}" : $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}"
@ -1035,7 +1067,17 @@ public partial class OrderService
if (candidates.Count == 0) 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 return new AllocationEvaluation
{ {
StoredScheme = storedScheme, StoredScheme = storedScheme,
@ -1046,11 +1088,9 @@ public partial class OrderService
ZoneKeys = zoneKeys, ZoneKeys = zoneKeys,
Candidates = candidates, Candidates = candidates,
Skipped = skipped, Skipped = skipped,
FailReason = chargeableWeight <= 0 PoolCount = poolCount,
? "计费重无效,无法计算应付费用" ConditionPassCount = conditionPassCount,
: string.IsNullOrWhiteSpace(detail) FailReason = failReason
? "排货规则未匹配到有效渠道买价"
: $"排货规则未匹配到有效渠道买价;{detail}"
}; };
} }
@ -1065,6 +1105,8 @@ public partial class OrderService
ZoneKeys = zoneKeys, ZoneKeys = zoneKeys,
Candidates = candidates, Candidates = candidates,
Skipped = skipped, Skipped = skipped,
PoolCount = poolCount,
ConditionPassCount = conditionPassCount,
Selected = selected Selected = selected
}; };
} }
@ -1127,6 +1169,9 @@ public partial class OrderService
SelectedChannelName = evaluation.Selected?.Channel.ChannelName, SelectedChannelName = evaluation.Selected?.Channel.ChannelName,
FailReason = evaluation.FailReason, FailReason = evaluation.FailReason,
TraceTime = DateTime.Now, TraceTime = DateTime.Now,
PoolCount = evaluation.PoolCount,
ConditionPassCount = evaluation.ConditionPassCount,
CandidateCount = candidates.Count,
Candidates = candidates, Candidates = candidates,
Skipped = evaluation.Skipped Skipped = evaluation.Skipped
}; };
@ -1255,7 +1300,8 @@ public partial class OrderService
ServiceChannelId = provider.ServiceChannelId, ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null, ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
Weight = provider.Weight, Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [] CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
MatchCondition = provider.MatchCondition
})).ToList(); })).ToList();
await AllocationProviderSnapshotEnricher.EnrichAsync(providers); await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
} }

View File

@ -1,4 +1,6 @@
using Oms.Application.Dtos.Channels; 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.Consts;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Entities;
@ -10,6 +12,13 @@ namespace Oms.Application.Shared;
/// </summary> /// </summary>
public static class AllocationResolver 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 sealed class ChannelCandidate
{ {
public SalesProductAllocationProviderSnapshotDto Provider { get; init; } = null!; public SalesProductAllocationProviderSnapshotDto Provider { get; init; } = null!;
@ -22,16 +31,140 @@ public static class AllocationResolver
public IReadOnlyList<OrderFee> PayableFees { get; init; } = []; public IReadOnlyList<OrderFee> PayableFees { get; init; } = [];
} }
public sealed class FilterProvidersResult
{
public List<SalesProductAllocationProviderSnapshotDto> Passed { get; init; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
public int PoolCount { get; init; }
public int ConditionPassCount { get; init; }
}
/// <summary>
/// 兼容旧调用:仅按客户名单过滤(无判断条件上下文时)。
/// </summary>
public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders( public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders(
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers, IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
int? customerId) => int? customerId) =>
providers FilterProvidersDetailed(providers, customerId, context: null).Passed;
/// <summary>
/// 先按判断条件(或兼容客户名单)筛出可用渠道;空条件恒真。
/// </summary>
public static FilterProvidersResult FilterProvidersDetailed(
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
int? customerId,
OrderRuleEvaluationContext? context)
{
var pool = providers
.Where(x => x.ServiceChannelId.HasValue && x.ServiceChannelId > 0) .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) .OrderBy(x => x.SerialNumber)
.ToList(); .ToList();
var passed = new List<SalesProductAllocationProviderSnapshotDto>();
var skipped = new List<OrderAllocationSkippedOutput>();
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<SalesProductAllocationProviderSnapshotDto> DeduplicateByChannel(
List<SalesProductAllocationProviderSnapshotDto> 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<ChannelCandidate> candidates) => public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList<ChannelCandidate> candidates) =>
SelectCandidate(scheme, candidates).Channel; SelectCandidate(scheme, candidates).Channel;

View File

@ -67,7 +67,8 @@ public static class PublishSnapshotSlimmer
ServiceChannelId = provider.ServiceChannelId, ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelName, ServiceChannelName = provider.ServiceChannelName,
Weight = provider.Weight, Weight = provider.Weight,
CustomerIds = provider.CustomerIds CustomerIds = provider.CustomerIds,
MatchCondition = provider.MatchCondition
}; };
public static WeightRuleSnapshotRuleDto SlimWeightRule(WeightRuleInfo entity) => public static WeightRuleSnapshotRuleDto SlimWeightRule(WeightRuleInfo entity) =>

View File

@ -18,4 +18,10 @@ public class SalesProductAllocationProvider : OmsAuditedEntity
[SugarColumn(ColumnDataType = "json", IsNullable = true)] [SugarColumn(ColumnDataType = "json", IsNullable = true)]
public string? CustomerIds { get; set; } public string? CustomerIds { get; set; }
/// <summary>
/// 排货判断条件(中文公式);空表示全部订单可用。
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string? MatchCondition { get; set; }
} }

View File

@ -1,8 +1,11 @@
using FluentAssertions; using FluentAssertions;
using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Channels;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Tests; namespace Oms.Tests;
@ -23,6 +26,159 @@ public class AllocationResolverTests
filtered.Select(x => x.ServiceChannelId).Should().BeEquivalentTo([1, 2]); filtered.Select(x => x.ServiceChannelId).Should().BeEquivalentTo([1, 2]);
} }
[Fact]
public void FilterProvidersDetailed_EmptyCondition_ShouldPass()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
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<SalesProductAllocationProviderSnapshotDto>
{
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<SalesProductAllocationProviderSnapshotDto>
{
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<SalesProductAllocationProviderSnapshotDto>
{
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<SalesProductAllocationProviderSnapshotDto>
{
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<SalesProductAllocationProviderSnapshotDto>
{
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<SalesProductAllocationProviderSnapshotDto>
{
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] [Fact]
public void SelectChannel_CostLowest_ShouldPickCheapest() public void SelectChannel_CostLowest_ShouldPickCheapest()
{ {
@ -56,6 +212,20 @@ public class AllocationResolverTests
channel.Id.Should().Be(2); 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<AllocationResolver.ChannelCandidate> BuildCandidates( private static List<AllocationResolver.ChannelCandidate> BuildCandidates(
params (int channelId, decimal cost, int minTime, int weight)[] rows) params (int channelId, decimal cost, int minTime, int weight)[] rows)
{ {