feat: 排货规则支持判断条件公式并完善失败溯源
All checks were successful
Build and Deploy / deploy (push) Successful in 1m49s
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:
parent
6b920cc49d
commit
e5c9364434
3
db.sql
3
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 '创建时间',
|
||||
|
||||
21
sql/migrate_allocation_match_condition.sql
Normal file
21
sql/migrate_allocation_match_condition.sql
Normal 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;
|
||||
@ -31,7 +31,12 @@ public class SalesProductAllocationProviderInput
|
||||
public int Weight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户主键集合。
|
||||
/// 客户主键集合(兼容旧配置;新配置请用 MatchCondition)。
|
||||
/// </summary>
|
||||
public List<int> CustomerIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 排货判断条件(中文公式);空表示全部订单可用。
|
||||
/// </summary>
|
||||
public string? MatchCondition { get; set; }
|
||||
}
|
||||
|
||||
@ -41,10 +41,15 @@ public class SalesProductAllocationProviderOutput
|
||||
public int Weight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户主键集合。
|
||||
/// 客户主键集合(兼容旧配置)。
|
||||
/// </summary>
|
||||
public List<int> CustomerIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 排货判断条件(中文公式);空表示全部订单可用。
|
||||
/// </summary>
|
||||
public string? MatchCondition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@ -41,10 +41,15 @@ public class SalesProductAllocationProviderSnapshotDto
|
||||
public int Weight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 指定客户主键集合,空表示全部。
|
||||
/// 指定客户主键集合,空表示全部(兼容旧快照)。
|
||||
/// </summary>
|
||||
public List<int> CustomerIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 排货判断条件(中文公式);空表示全部订单可用。
|
||||
/// </summary>
|
||||
public string? MatchCondition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务渠道编码(发布快照固化)。
|
||||
/// </summary>
|
||||
|
||||
@ -36,6 +36,15 @@ public class OrderAllocationTraceOutput
|
||||
|
||||
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<OrderAllocationSkippedOutput> Skipped { get; set; } = [];
|
||||
@ -71,6 +80,12 @@ public class OrderAllocationSkippedOutput
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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<List<int>>(provider.CustomerIds) ?? [],
|
||||
CreateTime = provider.CreateTime,
|
||||
UpdateTime = OmsDocHelper.NullIfDefault(provider.UpdateTime)
|
||||
Weight = provider.Weight,
|
||||
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<int>();
|
||||
foreach (var provider in filtered)
|
||||
{
|
||||
|
||||
@ -861,6 +861,8 @@ public partial class OrderService
|
||||
public IReadOnlyList<string> ZoneKeys { get; init; } = [];
|
||||
public List<AllocationResolver.ChannelCandidate> Candidates { 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 string? FailReason { get; set; }
|
||||
}
|
||||
@ -875,10 +877,18 @@ public partial class OrderService
|
||||
string? schemeOverride,
|
||||
int? forceServiceChannelId)
|
||||
{
|
||||
var skipped = new List<OrderAllocationSkippedOutput>();
|
||||
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<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;
|
||||
|
||||
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<List<int>>(provider.CustomerIds) ?? []
|
||||
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
|
||||
MatchCondition = provider.MatchCondition
|
||||
})).ToList();
|
||||
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
/// </summary>
|
||||
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<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(
|
||||
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
|
||||
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.CustomerIds.Count == 0
|
||||
|| (customerId.HasValue && x.CustomerIds.Contains(customerId.Value)))
|
||||
.OrderBy(x => x.SerialNumber)
|
||||
.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) =>
|
||||
SelectCandidate(scheme, candidates).Channel;
|
||||
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -18,4 +18,10 @@ public class SalesProductAllocationProvider : OmsAuditedEntity
|
||||
|
||||
[SugarColumn(ColumnDataType = "json", IsNullable = true)]
|
||||
public string? CustomerIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排货判断条件(中文公式);空表示全部订单可用。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
|
||||
public string? MatchCondition { get; set; }
|
||||
}
|
||||
|
||||
@ -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<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]
|
||||
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<AllocationResolver.ChannelCandidate> BuildCandidates(
|
||||
params (int channelId, decimal cost, int minTime, int weight)[] rows)
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user