feat: 自动触发公式引擎与查价运输方式分组接口
实现中文公式预报前拦截、规则测试 API 及按销售产品分组返回查价运输方式选项。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d3a95a6bad
commit
43563b727f
2
db.sql
2
db.sql
@ -730,6 +730,8 @@ CREATE TABLE `t_weight_rounding_info` (
|
|||||||
CREATE TABLE `t_auto_trigger` (
|
CREATE TABLE `t_auto_trigger` (
|
||||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
|
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
|
||||||
`Status` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
|
`Status` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||||
|
`RuleName` VARCHAR(128) NULL COMMENT '规则名称',
|
||||||
|
`Priority` INT NOT NULL DEFAULT 100 COMMENT '优先级,数值越小越先执行',
|
||||||
`Operation` VARCHAR(32) NULL COMMENT '操作:拦截/警告/问题件',
|
`Operation` VARCHAR(32) NULL COMMENT '操作:拦截/警告/问题件',
|
||||||
`TriggerTiming` VARCHAR(32) NULL COMMENT '触发时机:下单/入库/出库',
|
`TriggerTiming` VARCHAR(32) NULL COMMENT '触发时机:下单/入库/出库',
|
||||||
`TriggerCondition` TEXT COMMENT '触发条件',
|
`TriggerCondition` TEXT COMMENT '触发条件',
|
||||||
|
|||||||
18
sql/migrate_t_auto_trigger_rule_meta.sql
Normal file
18
sql/migrate_t_auto_trigger_rule_meta.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-- t_auto_trigger 规则名与优先级(可重复执行)
|
||||||
|
-- 在 OMS 业务库执行:mysql -h<host> -u<user> -p<db> < migrate_t_auto_trigger_rule_meta.sql
|
||||||
|
|
||||||
|
SET @db = DATABASE();
|
||||||
|
|
||||||
|
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_auto_trigger' AND COLUMN_NAME = 'RuleName');
|
||||||
|
SET @sql := IF(@exist = 0,
|
||||||
|
'ALTER TABLE `t_auto_trigger` ADD COLUMN `RuleName` VARCHAR(128) NULL COMMENT ''规则名称'' AFTER `Status`',
|
||||||
|
'SELECT ''skip RuleName''');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_auto_trigger' AND COLUMN_NAME = 'Priority');
|
||||||
|
SET @sql := IF(@exist = 0,
|
||||||
|
'ALTER TABLE `t_auto_trigger` ADD COLUMN `Priority` INT NOT NULL DEFAULT 100 COMMENT ''优先级,数值越小越先执行'' AFTER `RuleName`',
|
||||||
|
'SELECT ''skip Priority''');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
32
src/Oms.Application/Dtos/Channels/AutoTriggerMetaOutput.cs
Normal file
32
src/Oms.Application/Dtos/Channels/AutoTriggerMetaOutput.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
namespace Oms.Application.Dtos.Channels;
|
||||||
|
|
||||||
|
public class AutoTriggerMetaOutput
|
||||||
|
{
|
||||||
|
public List<AutoTriggerFormulaCategoryOutput> Categories { get; set; } = [];
|
||||||
|
|
||||||
|
public List<AutoTriggerFormulaFieldOutput> Fields { get; set; } = [];
|
||||||
|
|
||||||
|
public string OversizeExampleFormula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int FormulaMaxLength { get; set; } = 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoTriggerFormulaCategoryOutput
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoTriggerFormulaFieldOutput
|
||||||
|
{
|
||||||
|
public string Table { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Field { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ValueType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Virtual { get; set; }
|
||||||
|
}
|
||||||
@ -15,6 +15,16 @@ public class AutoTriggerOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public byte Status { get; set; }
|
public byte Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规则名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? RuleName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先级。
|
||||||
|
/// </summary>
|
||||||
|
public int Priority { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 操作类型。
|
/// 操作类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -17,6 +17,16 @@ public class AutoTriggerSaveInput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public EnableStatus Status { get; set; }
|
public EnableStatus Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规则名称。
|
||||||
|
/// </summary>
|
||||||
|
public string? RuleName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先级,数值越小越先执行。
|
||||||
|
/// </summary>
|
||||||
|
public int Priority { get; set; } = 100;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 操作类型。
|
/// 操作类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
49
src/Oms.Application/Dtos/Channels/AutoTriggerTestInput.cs
Normal file
49
src/Oms.Application/Dtos/Channels/AutoTriggerTestInput.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
namespace Oms.Application.Dtos.Channels;
|
||||||
|
|
||||||
|
public class AutoTriggerTestInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 公式正文(与 TriggerCondition 二选一)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Expression { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 完整触发条件(纯公式或 JSON 包装)。
|
||||||
|
/// </summary>
|
||||||
|
public string? TriggerCondition { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用已有订单作为评估上下文。
|
||||||
|
/// </summary>
|
||||||
|
public int? OrderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用样例数据作为预报前评估上下文。
|
||||||
|
/// </summary>
|
||||||
|
public AutoTriggerTestSampleInput? Sample { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoTriggerTestSampleInput
|
||||||
|
{
|
||||||
|
public string? ProductCode { get; set; }
|
||||||
|
|
||||||
|
public string? ChannelCode { get; set; }
|
||||||
|
|
||||||
|
public string? ConsigneeCountry { get; set; }
|
||||||
|
|
||||||
|
public string? ConsigneeCountryName { get; set; }
|
||||||
|
|
||||||
|
public int? CustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public string? ClientNo { get; set; }
|
||||||
|
|
||||||
|
public decimal? Length { get; set; }
|
||||||
|
|
||||||
|
public decimal? Width { get; set; }
|
||||||
|
|
||||||
|
public decimal? Height { get; set; }
|
||||||
|
|
||||||
|
public decimal? ActualWeight { get; set; }
|
||||||
|
}
|
||||||
12
src/Oms.Application/Dtos/Channels/AutoTriggerTestOutput.cs
Normal file
12
src/Oms.Application/Dtos/Channels/AutoTriggerTestOutput.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Oms.Application.Dtos.Channels;
|
||||||
|
|
||||||
|
public class AutoTriggerTestOutput
|
||||||
|
{
|
||||||
|
public bool Matched { get; set; }
|
||||||
|
|
||||||
|
public string? Expression { get; set; }
|
||||||
|
|
||||||
|
public string? ContextType { get; set; }
|
||||||
|
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查价运输方式分组选项。
|
||||||
|
/// </summary>
|
||||||
|
public class OrderPriceCheckShippingOptionsOutput
|
||||||
|
{
|
||||||
|
public List<OrderPriceCheckShippingGroupOutput> Groups { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderPriceCheckShippingGroupOutput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分组编码(字典 sales_product_group.dictCode)。
|
||||||
|
/// </summary>
|
||||||
|
public string GroupCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分组名称(字典 sales_product_group.dictName)。
|
||||||
|
/// </summary>
|
||||||
|
public string GroupName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public List<OrderPriceCheckShippingOptionOutput> Products { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderPriceCheckShippingOptionOutput
|
||||||
|
{
|
||||||
|
public int ProductId { get; set; }
|
||||||
|
|
||||||
|
public string ProductCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下拉展示文案。
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ using Fake.Application;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Oms.Application.Dtos.Channels;
|
using Oms.Application.Dtos.Channels;
|
||||||
using Oms.Application.Dtos.Shared;
|
using Oms.Application.Dtos.Shared;
|
||||||
|
using Oms.Application.Services.Orders.Issues;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Domain.ChannelsAggregate.Entities;
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
using Oms.Domain.ChannelsAggregate.Repositories;
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
@ -16,12 +17,11 @@ namespace Oms.Application.Services.Channels;
|
|||||||
public class AutoTriggerService : ApplicationService
|
public class AutoTriggerService : ApplicationService
|
||||||
{
|
{
|
||||||
private IAutoTriggerRepository AutoTriggerRepository => LazyServiceProvider.GetRequiredService<IAutoTriggerRepository>();
|
private IAutoTriggerRepository AutoTriggerRepository => LazyServiceProvider.GetRequiredService<IAutoTriggerRepository>();
|
||||||
|
private OrderAutoTriggerService OrderAutoTriggerService => LazyServiceProvider.GetRequiredService<OrderAutoTriggerService>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询自动触发规则列表。
|
/// 查询自动触发规则列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:自动触发规则列表查询条件。</param>
|
|
||||||
/// <returns>自动触发规则分页结果,包含列表数据和总记录数。</returns>
|
|
||||||
[HttpGet("/channel/auto-trigger/list")]
|
[HttpGet("/channel/auto-trigger/list")]
|
||||||
public async Task<OmsPageResult<AutoTriggerOutput>> GetListAsync([FromQuery] AutoTriggerListInput input)
|
public async Task<OmsPageResult<AutoTriggerOutput>> GetListAsync([FromQuery] AutoTriggerListInput input)
|
||||||
{
|
{
|
||||||
@ -32,23 +32,37 @@ public class AutoTriggerService : ApplicationService
|
|||||||
predicate,
|
predicate,
|
||||||
pageIndex,
|
pageIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
new Dictionary<string, bool> { [nameof(AutoTrigger.UpdateTime)] = false, [nameof(AutoTrigger.Id)] = false });
|
new Dictionary<string, bool> { [nameof(AutoTrigger.Priority)] = true, [nameof(AutoTrigger.Id)] = true });
|
||||||
var total = await AutoTriggerRepository.CountAsync(predicate);
|
var total = await AutoTriggerRepository.CountAsync(predicate);
|
||||||
var items = entities.Select(MapOutput).ToList();
|
var items = entities.Select(MapOutput).ToList();
|
||||||
return new OmsPageResult<AutoTriggerOutput> { Items = items, Total = total };
|
return new OmsPageResult<AutoTriggerOutput> { Items = items, Total = total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取公式字段元数据。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/channel/auto-trigger/meta")]
|
||||||
|
public Task<AutoTriggerMetaOutput> GetMetaAsync() =>
|
||||||
|
Task.FromResult(AutoTriggerFormulaMeta.BuildMetaOutput());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试公式是否命中。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/channel/auto-trigger/test")]
|
||||||
|
public Task<AutoTriggerTestOutput> TestFormulaAsync(AutoTriggerTestInput input) =>
|
||||||
|
OrderAutoTriggerService.TestFormulaAsync(input);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增自动触发规则。
|
/// 新增自动触发规则。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:自动触发规则保存参数。</param>
|
|
||||||
/// <returns>无返回体,仅完成新增自动触发规则。</returns>
|
|
||||||
[HttpPost("/channel/auto-trigger/create")]
|
[HttpPost("/channel/auto-trigger/create")]
|
||||||
public async Task CreateAsync(AutoTriggerSaveInput input)
|
public async Task CreateAsync(AutoTriggerSaveInput input)
|
||||||
{
|
{
|
||||||
var entity = new AutoTrigger
|
var entity = new AutoTrigger
|
||||||
{
|
{
|
||||||
Status = input.Status,
|
Status = input.Status,
|
||||||
|
RuleName = NormalizeRuleName(input),
|
||||||
|
Priority = NormalizePriority(input),
|
||||||
Operation = input.Operation,
|
Operation = input.Operation,
|
||||||
TriggerTiming = input.TriggerTiming,
|
TriggerTiming = input.TriggerTiming,
|
||||||
TriggerCondition = input.TriggerCondition,
|
TriggerCondition = input.TriggerCondition,
|
||||||
@ -61,13 +75,13 @@ public class AutoTriggerService : ApplicationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新自动触发规则。
|
/// 更新自动触发规则。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:自动触发规则保存参数。</param>
|
|
||||||
/// <returns>无返回体,仅完成更新自动触发规则。</returns>
|
|
||||||
[HttpPost("/channel/auto-trigger/update")]
|
[HttpPost("/channel/auto-trigger/update")]
|
||||||
public async Task UpdateAsync(AutoTriggerSaveInput input)
|
public async Task UpdateAsync(AutoTriggerSaveInput input)
|
||||||
{
|
{
|
||||||
var entity = await GetEntityOrThrowAsync(input.Id);
|
var entity = await GetEntityOrThrowAsync(input.Id);
|
||||||
entity.Status = input.Status;
|
entity.Status = input.Status;
|
||||||
|
entity.RuleName = NormalizeRuleName(input);
|
||||||
|
entity.Priority = NormalizePriority(input);
|
||||||
entity.Operation = input.Operation;
|
entity.Operation = input.Operation;
|
||||||
entity.TriggerTiming = input.TriggerTiming;
|
entity.TriggerTiming = input.TriggerTiming;
|
||||||
entity.TriggerCondition = input.TriggerCondition;
|
entity.TriggerCondition = input.TriggerCondition;
|
||||||
@ -79,8 +93,6 @@ public class AutoTriggerService : ApplicationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 删除自动触发规则。
|
/// 删除自动触发规则。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:目标主键。</param>
|
|
||||||
/// <returns>无返回体,仅完成删除自动触发规则。</returns>
|
|
||||||
[HttpPost("/channel/auto-trigger/delete")]
|
[HttpPost("/channel/auto-trigger/delete")]
|
||||||
public async Task DeleteAsync(IdInput input)
|
public async Task DeleteAsync(IdInput input)
|
||||||
{
|
{
|
||||||
@ -91,8 +103,6 @@ public class AutoTriggerService : ApplicationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 切换自动触发规则状态。
|
/// 切换自动触发规则状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:目标主键和状态值。</param>
|
|
||||||
/// <returns>无返回体,仅完成切换自动触发规则状态。</returns>
|
|
||||||
[HttpPost("/channel/auto-trigger/toggleStatus")]
|
[HttpPost("/channel/auto-trigger/toggleStatus")]
|
||||||
public async Task ToggleStatusAsync(ToggleStatusInput input)
|
public async Task ToggleStatusAsync(ToggleStatusInput input)
|
||||||
{
|
{
|
||||||
@ -126,12 +136,35 @@ public class AutoTriggerService : ApplicationService
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeRuleName(AutoTriggerSaveInput input)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(input.RuleName))
|
||||||
|
{
|
||||||
|
return input.RuleName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AutoTriggerConditionParser.Parse(input.TriggerCondition).RuleName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizePriority(AutoTriggerSaveInput input)
|
||||||
|
{
|
||||||
|
if (input.Priority > 0)
|
||||||
|
{
|
||||||
|
return input.Priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = AutoTriggerConditionParser.Parse(input.TriggerCondition);
|
||||||
|
return parsed.Priority > 0 ? parsed.Priority : 100;
|
||||||
|
}
|
||||||
|
|
||||||
private static AutoTriggerOutput MapOutput(AutoTrigger entity)
|
private static AutoTriggerOutput MapOutput(AutoTrigger entity)
|
||||||
{
|
{
|
||||||
return new AutoTriggerOutput
|
return new AutoTriggerOutput
|
||||||
{
|
{
|
||||||
Id = entity.Id,
|
Id = entity.Id,
|
||||||
Status = (byte)entity.Status,
|
Status = (byte)entity.Status,
|
||||||
|
RuleName = AutoTriggerRuleResolver.GetRuleName(entity),
|
||||||
|
Priority = AutoTriggerRuleResolver.GetPriority(entity),
|
||||||
Operation = entity.Operation,
|
Operation = entity.Operation,
|
||||||
TriggerTiming = entity.TriggerTiming,
|
TriggerTiming = entity.TriggerTiming,
|
||||||
TriggerCondition = entity.TriggerCondition,
|
TriggerCondition = entity.TriggerCondition,
|
||||||
|
|||||||
@ -6,7 +6,10 @@ namespace Oms.Application.Services.Orders.Issues;
|
|||||||
|
|
||||||
public static class AutoTriggerConditionMatcher
|
public static class AutoTriggerConditionMatcher
|
||||||
{
|
{
|
||||||
public static bool Matches(Order order, string? triggerCondition)
|
public static bool Matches(Order order, string? triggerCondition) =>
|
||||||
|
Matches(OrderRuleEvaluationContext.FromOrderOnly(order), triggerCondition);
|
||||||
|
|
||||||
|
public static bool Matches(OrderRuleEvaluationContext context, string? triggerCondition)
|
||||||
{
|
{
|
||||||
var text = triggerCondition?.Trim();
|
var text = triggerCondition?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
@ -14,18 +17,37 @@ public static class AutoTriggerConditionMatcher
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.StartsWith('{') && text.EndsWith('}'))
|
var parsed = AutoTriggerConditionParser.Parse(text);
|
||||||
|
if (ShouldUseFormulaEvaluator(parsed, text))
|
||||||
{
|
{
|
||||||
return MatchesJson(order, text);
|
if (string.IsNullOrWhiteSpace(parsed.Expression))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ChineseFormulaEvaluator.TryEvaluate(parsed.Expression, context, out var result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var lowered = text.ToLowerInvariant();
|
if (parsed.IsLegacyJsonMatcher && text.StartsWith('{') && text.EndsWith('}'))
|
||||||
if (IssueTypeCodes.All.Contains(lowered))
|
|
||||||
{
|
{
|
||||||
return true;
|
return MatchesLegacyJson(context.Order, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ContainsKeyword(order, text);
|
if (context.Order != null)
|
||||||
|
{
|
||||||
|
var lowered = text.ToLowerInvariant();
|
||||||
|
if (IssueTypeCodes.All.Contains(lowered))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContainsKeyword(context.Order, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string ResolveIssueType(string? triggerCondition)
|
public static string ResolveIssueType(string? triggerCondition)
|
||||||
@ -80,8 +102,18 @@ public static class AutoTriggerConditionMatcher
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesJson(Order order, string json)
|
private static bool ShouldUseFormulaEvaluator(ParsedTriggerCondition parsed, string rawText) =>
|
||||||
|
parsed.IsFormulaVersion
|
||||||
|
|| (!string.IsNullOrWhiteSpace(parsed.Expression) && AutoTriggerConditionParser.LooksLikeFormula(parsed.Expression))
|
||||||
|
|| (!parsed.IsLegacyJsonMatcher && AutoTriggerConditionParser.LooksLikeFormula(rawText));
|
||||||
|
|
||||||
|
private static bool MatchesLegacyJson(Order? order, string json)
|
||||||
{
|
{
|
||||||
|
if (order == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
|
public sealed record ParsedTriggerCondition(
|
||||||
|
string? Expression,
|
||||||
|
string? RuleName,
|
||||||
|
int Priority,
|
||||||
|
bool IsFormulaVersion,
|
||||||
|
bool IsLegacyJsonMatcher)
|
||||||
|
{
|
||||||
|
public static ParsedTriggerCondition Empty { get; } = new(null, null, 100, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AutoTriggerConditionParser
|
||||||
|
{
|
||||||
|
public static ParsedTriggerCondition Parse(string? raw)
|
||||||
|
{
|
||||||
|
var text = raw?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return ParsedTriggerCondition.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.StartsWith('{') || !text.EndsWith('}'))
|
||||||
|
{
|
||||||
|
return new ParsedTriggerCondition(text, null, 100, LooksLikeFormula(text), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.TryGetProperty("version", out var versionElement))
|
||||||
|
{
|
||||||
|
var version = versionElement.GetString()?.Trim();
|
||||||
|
if (string.Equals(version, "formula", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var expression = root.TryGetProperty("expression", out var exprElement)
|
||||||
|
? exprElement.GetString()?.Trim()
|
||||||
|
: null;
|
||||||
|
var ruleName = root.TryGetProperty("ruleName", out var nameElement)
|
||||||
|
? nameElement.GetString()?.Trim()
|
||||||
|
: null;
|
||||||
|
var priority = root.TryGetProperty("priority", out var priorityElement) && priorityElement.TryGetInt32(out var p)
|
||||||
|
? p
|
||||||
|
: 100;
|
||||||
|
return new ParsedTriggerCondition(expression, ruleName, priority, true, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("orderStatus", out _)
|
||||||
|
|| root.TryGetProperty("productCode", out _)
|
||||||
|
|| root.TryGetProperty("channelCode", out _)
|
||||||
|
|| root.TryGetProperty("keyword", out _))
|
||||||
|
{
|
||||||
|
return new ParsedTriggerCondition(null, null, 100, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("issueType", out _))
|
||||||
|
{
|
||||||
|
return new ParsedTriggerCondition(null, null, 100, false, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedTriggerCondition(text, null, 100, LooksLikeFormula(text), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool LooksLikeFormula(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.Contains("包含('", StringComparison.Ordinal)
|
||||||
|
|| text.Contains("&&", StringComparison.Ordinal)
|
||||||
|
|| text.Contains("||", StringComparison.Ordinal)
|
||||||
|
|| text.Contains("==", StringComparison.Ordinal)
|
||||||
|
|| text.Contains("!=", StringComparison.Ordinal)
|
||||||
|
|| text.Contains(">=", StringComparison.Ordinal)
|
||||||
|
|| text.Contains("<=", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var label in AutoTriggerFormulaMeta.LabelsByLengthDesc)
|
||||||
|
{
|
||||||
|
if (text.Contains(label, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,255 @@
|
|||||||
|
namespace Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
|
using Oms.Application.Dtos.Channels;
|
||||||
|
|
||||||
|
public enum FormulaValueType
|
||||||
|
{
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Boolean,
|
||||||
|
DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FormulaFieldRef(string Table, string Field, bool Virtual = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 中文字段名 → 库表字段映射(与前端 formulaMeta.ts 保持一致)。
|
||||||
|
/// </summary>
|
||||||
|
public static class AutoTriggerFormulaMeta
|
||||||
|
{
|
||||||
|
public static IReadOnlyDictionary<string, FormulaFieldRef> LabelToRef { get; }
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, FormulaValueType> LabelValueTypes { get; }
|
||||||
|
|
||||||
|
/// <summary>按长度降序排列的标签,供词法分析最长匹配。</summary>
|
||||||
|
public static IReadOnlyList<string> LabelsByLengthDesc { get; }
|
||||||
|
|
||||||
|
static AutoTriggerFormulaMeta()
|
||||||
|
{
|
||||||
|
var fields = BuildFieldDefs();
|
||||||
|
var labelToRef = new Dictionary<string, FormulaFieldRef>(StringComparer.Ordinal);
|
||||||
|
var labelValueTypes = new Dictionary<string, FormulaValueType>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var (label, table, field, valueType, virtualField) in fields)
|
||||||
|
{
|
||||||
|
labelToRef[label] = new FormulaFieldRef(table, field, virtualField);
|
||||||
|
labelValueTypes[label] = valueType;
|
||||||
|
}
|
||||||
|
|
||||||
|
LabelToRef = labelToRef;
|
||||||
|
LabelValueTypes = labelValueTypes;
|
||||||
|
LabelsByLengthDesc = labelToRef.Keys.OrderByDescending(x => x.Length).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryResolve(string label, out FormulaFieldRef? fieldRef) =>
|
||||||
|
LabelToRef.TryGetValue(label, out fieldRef);
|
||||||
|
|
||||||
|
public static AutoTriggerMetaOutput BuildMetaOutput()
|
||||||
|
{
|
||||||
|
var categories = new List<AutoTriggerFormulaCategoryOutput>
|
||||||
|
{
|
||||||
|
new() { Key = "t_order", Label = "订单" },
|
||||||
|
new() { Key = "t_order_recipient", Label = "收件人" },
|
||||||
|
new() { Key = "t_order_sender", Label = "发件人" },
|
||||||
|
new() { Key = "t_order_declaration", Label = "申报" },
|
||||||
|
new() { Key = "t_order_weight", Label = "件重尺" },
|
||||||
|
new() { Key = "t_order_fee", Label = "费用" },
|
||||||
|
new() { Key = "t_customer", Label = "客户" },
|
||||||
|
new() { Key = "_virtual", Label = "常用" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var fields = LabelToRef
|
||||||
|
.Select(x =>
|
||||||
|
{
|
||||||
|
LabelValueTypes.TryGetValue(x.Key, out var valueType);
|
||||||
|
return new AutoTriggerFormulaFieldOutput
|
||||||
|
{
|
||||||
|
Label = x.Key,
|
||||||
|
Table = x.Value.Table,
|
||||||
|
Field = x.Value.Field,
|
||||||
|
Virtual = x.Value.Virtual,
|
||||||
|
ValueType = valueType switch
|
||||||
|
{
|
||||||
|
FormulaValueType.String => "string",
|
||||||
|
FormulaValueType.Number => "number",
|
||||||
|
FormulaValueType.Boolean => "boolean",
|
||||||
|
FormulaValueType.DateTime => "datetime",
|
||||||
|
_ => "string"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderBy(x => x.Table)
|
||||||
|
.ThenBy(x => x.Label)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new AutoTriggerMetaOutput
|
||||||
|
{
|
||||||
|
Categories = categories,
|
||||||
|
Fields = fields,
|
||||||
|
OversizeExampleFormula =
|
||||||
|
"(单件最大长度()>60||单件最大宽度()>46||((单件最大长度()+单件最大宽度()+单件最大高度())>150))&&国家包含('GB')&&客户包含('阿里国际站/深圳一达通')&&!销售产品包含('EX_ASP_HKDHL_YD')",
|
||||||
|
FormulaMaxLength = 10000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<(string Label, string Table, string Field, FormulaValueType ValueType, bool Virtual)> BuildFieldDefs()
|
||||||
|
{
|
||||||
|
// t_order
|
||||||
|
yield return ("请求标识", "t_order", "RequestId", FormulaValueType.String, false);
|
||||||
|
yield return ("运单号", "t_order", "ServiceNo", FormulaValueType.String, false);
|
||||||
|
yield return ("客户单号", "t_order", "ClientNo", FormulaValueType.String, false);
|
||||||
|
yield return ("跟踪号", "t_order", "TrackNo", FormulaValueType.String, false);
|
||||||
|
yield return ("销售产品", "t_order", "ProductCode", FormulaValueType.String, false);
|
||||||
|
yield return ("收货渠道", "t_order", "ChannelCode", FormulaValueType.String, false);
|
||||||
|
yield return ("下游服务商", "t_order", "DownstreamSupplierCode", FormulaValueType.String, false);
|
||||||
|
yield return ("下游AppKey", "t_order", "DownstreamAppKey", FormulaValueType.String, false);
|
||||||
|
yield return ("下游AppId", "t_order", "DownstreamAppId", FormulaValueType.Number, false);
|
||||||
|
yield return ("渠道预报编码", "t_order", "ChannelForecastCode", FormulaValueType.String, false);
|
||||||
|
yield return ("国家", "t_order", "ConsigneeCountry", FormulaValueType.String, false);
|
||||||
|
yield return ("收件国家名称", "t_order", "ConsigneeCountryName", FormulaValueType.String, false);
|
||||||
|
yield return ("货物属性", "t_order", "CargoAttribute", FormulaValueType.String, false);
|
||||||
|
yield return ("出口报关方式", "t_order", "ExportCustomsMode", FormulaValueType.String, false);
|
||||||
|
yield return ("预报失败原因", "t_order", "PredeclareFailReason", FormulaValueType.String, false);
|
||||||
|
yield return ("规则版本号", "t_order", "SalesProductRulesVersion", FormulaValueType.String, false);
|
||||||
|
yield return ("价格版本号", "t_order", "SalesProductPriceVersion", FormulaValueType.String, false);
|
||||||
|
yield return ("订单状态", "t_order", "OrderStatus", FormulaValueType.String, false);
|
||||||
|
yield return ("处理子状态", "t_order", "HandleStatus", FormulaValueType.String, false);
|
||||||
|
yield return ("问题类型", "t_order", "IssueType", FormulaValueType.String, false);
|
||||||
|
yield return ("问题标题", "t_order", "IssueTitle", FormulaValueType.String, false);
|
||||||
|
yield return ("问题内容", "t_order", "IssueContent", FormulaValueType.String, false);
|
||||||
|
yield return ("问题模板", "t_order", "IssueTemplateId", FormulaValueType.Number, false);
|
||||||
|
yield return ("问题备注", "t_order", "IssueRemark", FormulaValueType.String, false);
|
||||||
|
yield return ("问题件时间", "t_order", "IssueTime", FormulaValueType.DateTime, false);
|
||||||
|
yield return ("理赔类型", "t_order", "ClaimType", FormulaValueType.String, false);
|
||||||
|
yield return ("索赔金额", "t_order", "ClaimAmount", FormulaValueType.Number, false);
|
||||||
|
yield return ("索赔币种", "t_order", "ClaimCurrency", FormulaValueType.String, false);
|
||||||
|
yield return ("理赔原因", "t_order", "ClaimReason", FormulaValueType.String, false);
|
||||||
|
yield return ("理赔时间", "t_order", "ClaimTime", FormulaValueType.DateTime, false);
|
||||||
|
yield return ("异常前状态", "t_order", "PreviousOrderStatus", FormulaValueType.String, false);
|
||||||
|
yield return ("仓库", "t_order", "WarehouseId", FormulaValueType.Number, false);
|
||||||
|
yield return ("订单客户", "t_order", "CustomerId", FormulaValueType.Number, false);
|
||||||
|
yield return ("组织", "t_order", "OrganizationId", FormulaValueType.String, false);
|
||||||
|
yield return ("密级", "t_order", "SecretLevel", FormulaValueType.Number, false);
|
||||||
|
yield return ("发件人关联", "t_order", "SenderId", FormulaValueType.Number, false);
|
||||||
|
yield return ("收件人关联", "t_order", "RecipientId", FormulaValueType.Number, false);
|
||||||
|
yield return ("申报关联", "t_order", "DeclarationId", FormulaValueType.Number, false);
|
||||||
|
yield return ("收货时间", "t_order", "ReceiveTime", FormulaValueType.DateTime, false);
|
||||||
|
yield return ("发货时间", "t_order", "ShipTime", FormulaValueType.DateTime, false);
|
||||||
|
yield return ("支付时间", "t_order", "PayTime", FormulaValueType.DateTime, false);
|
||||||
|
yield return ("是否已支付", "t_order", "IsPaid", FormulaValueType.Boolean, false);
|
||||||
|
yield return ("签收时间", "t_order", "SignTime", FormulaValueType.DateTime, false);
|
||||||
|
yield return ("是否COD", "t_order", "IsCod", FormulaValueType.Boolean, false);
|
||||||
|
yield return ("COD币种", "t_order", "CodCurrency", FormulaValueType.String, false);
|
||||||
|
yield return ("COD金额", "t_order", "CodSum", FormulaValueType.Number, false);
|
||||||
|
yield return ("申报币种", "t_order", "DeclaredCurrency", FormulaValueType.String, false);
|
||||||
|
yield return ("申报总金额", "t_order", "DeclaredSum", FormulaValueType.Number, false);
|
||||||
|
yield return ("订单件数", "t_order", "Quantity", FormulaValueType.Number, false);
|
||||||
|
yield return ("是否拦截", "t_order", "IsIntercept", FormulaValueType.Boolean, false);
|
||||||
|
yield return ("是否偏远", "t_order", "IsRemote", FormulaValueType.Boolean, false);
|
||||||
|
|
||||||
|
// t_order_recipient
|
||||||
|
yield return ("收件人姓名", "t_order_recipient", "RecipientsName", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人公司", "t_order_recipient", "CompanyName", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人国家", "t_order_recipient", "Country", FormulaValueType.Number, false);
|
||||||
|
yield return ("收件人手机", "t_order_recipient", "Phone", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人电话", "t_order_recipient", "TelePhone", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人邮编", "t_order_recipient", "ZipCode", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人省州", "t_order_recipient", "Province", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人城市", "t_order_recipient", "City", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人门牌号", "t_order_recipient", "StreetNumber", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人地址1", "t_order_recipient", "StreetAddress1", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人地址2", "t_order_recipient", "StreetAddress2", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人地址3", "t_order_recipient", "StreetAddress3", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人邮箱", "t_order_recipient", "Email", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人税号类型", "t_order_recipient", "TaxType", FormulaValueType.Number, false);
|
||||||
|
yield return ("收件人税号", "t_order_recipient", "TaxNo", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人证件类型", "t_order_recipient", "CertificateType", FormulaValueType.Number, false);
|
||||||
|
yield return ("收件人证件号", "t_order_recipient", "CertificateNo", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人EORI", "t_order_recipient", "Eori", FormulaValueType.String, false);
|
||||||
|
yield return ("收件人VAT", "t_order_recipient", "Vat", FormulaValueType.String, false);
|
||||||
|
|
||||||
|
// t_order_sender
|
||||||
|
yield return ("发件人姓名", "t_order_sender", "SenderName", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人公司", "t_order_sender", "Company", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人国家", "t_order_sender", "Country", FormulaValueType.Number, false);
|
||||||
|
yield return ("发件人省州", "t_order_sender", "Province", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人城市", "t_order_sender", "City", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人邮编", "t_order_sender", "ZipCode", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人地址1", "t_order_sender", "StreetAddress1", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人地址2", "t_order_sender", "StreetAddress2", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人地址3", "t_order_sender", "StreetAddress3", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人门牌号", "t_order_sender", "HouseNumber", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人手机", "t_order_sender", "Phone", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人电话", "t_order_sender", "TelePhone", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人邮箱", "t_order_sender", "Email", FormulaValueType.String, false);
|
||||||
|
yield return ("发件人是否默认", "t_order_sender", "IsDefault", FormulaValueType.Boolean, false);
|
||||||
|
|
||||||
|
// t_order_declaration
|
||||||
|
yield return ("中文品名", "t_order_declaration", "CnName", FormulaValueType.String, false);
|
||||||
|
yield return ("英文品名", "t_order_declaration", "EnName", FormulaValueType.String, false);
|
||||||
|
yield return ("SKU", "t_order_declaration", "Sku", FormulaValueType.String, false);
|
||||||
|
yield return ("申报单位", "t_order_declaration", "Unit", FormulaValueType.String, false);
|
||||||
|
yield return ("申报单价", "t_order_declaration", "UnitPrice", FormulaValueType.Number, false);
|
||||||
|
yield return ("申报单重", "t_order_declaration", "UnitWeight", FormulaValueType.Number, false);
|
||||||
|
yield return ("申报数量", "t_order_declaration", "Quantity", FormulaValueType.Number, false);
|
||||||
|
yield return ("申报金额", "t_order_declaration", "Amount", FormulaValueType.Number, false);
|
||||||
|
yield return ("申报总重", "t_order_declaration", "Weight", FormulaValueType.Number, false);
|
||||||
|
yield return ("HS编码", "t_order_declaration", "HsCode", FormulaValueType.String, false);
|
||||||
|
yield return ("进口HS编码", "t_order_declaration", "ImHsCode", FormulaValueType.String, false);
|
||||||
|
yield return ("出口HS编码", "t_order_declaration", "ExHsCode", FormulaValueType.String, false);
|
||||||
|
yield return ("申报类别", "t_order_declaration", "Category", FormulaValueType.String, false);
|
||||||
|
yield return ("品牌", "t_order_declaration", "Brand", FormulaValueType.String, false);
|
||||||
|
yield return ("品牌类型", "t_order_declaration", "BrandType", FormulaValueType.String, false);
|
||||||
|
yield return ("是否带磁带电", "t_order_declaration", "IsMagnetic", FormulaValueType.Boolean, false);
|
||||||
|
yield return ("配货信息", "t_order_declaration", "Distribution", FormulaValueType.String, false);
|
||||||
|
yield return ("材质", "t_order_declaration", "Material", FormulaValueType.String, false);
|
||||||
|
yield return ("英文材质", "t_order_declaration", "EnMaterial", FormulaValueType.String, false);
|
||||||
|
yield return ("用途", "t_order_declaration", "Purpose", FormulaValueType.String, false);
|
||||||
|
yield return ("申报属性", "t_order_declaration", "Attributes", FormulaValueType.String, false);
|
||||||
|
yield return ("原产国", "t_order_declaration", "OriginCountry", FormulaValueType.String, false);
|
||||||
|
yield return ("销售链接", "t_order_declaration", "SalesLink", FormulaValueType.String, false);
|
||||||
|
yield return ("型号", "t_order_declaration", "Model", FormulaValueType.String, false);
|
||||||
|
yield return ("规格", "t_order_declaration", "Specifications", FormulaValueType.String, false);
|
||||||
|
yield return ("申报备注", "t_order_declaration", "Comment", FormulaValueType.String, false);
|
||||||
|
|
||||||
|
// t_order_weight
|
||||||
|
yield return ("件重尺类型", "t_order_weight", "Type", FormulaValueType.String, false);
|
||||||
|
yield return ("长度", "t_order_weight", "Length", FormulaValueType.Number, false);
|
||||||
|
yield return ("宽度", "t_order_weight", "Width", FormulaValueType.Number, false);
|
||||||
|
yield return ("高度", "t_order_weight", "Height", FormulaValueType.Number, false);
|
||||||
|
yield return ("实重", "t_order_weight", "ActualWeight", FormulaValueType.Number, false);
|
||||||
|
yield return ("体积重", "t_order_weight", "VolumetricWeight", FormulaValueType.Number, false);
|
||||||
|
yield return ("计费重", "t_order_weight", "ChargeableWeight", FormulaValueType.Number, false);
|
||||||
|
yield return ("体积", "t_order_weight", "Cubic", FormulaValueType.Number, false);
|
||||||
|
|
||||||
|
// t_order_fee
|
||||||
|
yield return ("费用类型", "t_order_fee", "Type", FormulaValueType.String, false);
|
||||||
|
yield return ("费用项", "t_order_fee", "CostItem", FormulaValueType.String, false);
|
||||||
|
yield return ("费用金额", "t_order_fee", "Amount", FormulaValueType.Number, false);
|
||||||
|
yield return ("费用币种", "t_order_fee", "Currency", FormulaValueType.String, false);
|
||||||
|
yield return ("费用下游服务商", "t_order_fee", "DownstreamProvider", FormulaValueType.String, false);
|
||||||
|
yield return ("价格编号", "t_order_fee", "PriceNo", FormulaValueType.String, false);
|
||||||
|
yield return ("价格生效时间", "t_order_fee", "PriceEffectiveTime", FormulaValueType.DateTime, false);
|
||||||
|
yield return ("计费阶段", "t_order_fee", "ChargingStage", FormulaValueType.String, false);
|
||||||
|
|
||||||
|
// t_customer
|
||||||
|
yield return ("客户编码", "t_customer", "CustomerCode", FormulaValueType.String, false);
|
||||||
|
yield return ("客户", "t_customer", "CustomerName", FormulaValueType.String, false);
|
||||||
|
yield return ("客户邮箱", "t_customer", "Email", FormulaValueType.String, false);
|
||||||
|
yield return ("客户手机", "t_customer", "Phone", FormulaValueType.String, false);
|
||||||
|
yield return ("客户余额", "t_customer", "Balance", FormulaValueType.Number, false);
|
||||||
|
yield return ("客户总额度", "t_customer", "TotalQuota", FormulaValueType.Number, false);
|
||||||
|
yield return ("客户已用额度", "t_customer", "UsedQuota", FormulaValueType.Number, false);
|
||||||
|
yield return ("客户状态", "t_customer", "Status", FormulaValueType.String, false);
|
||||||
|
yield return ("客户备注", "t_customer", "Remark", FormulaValueType.String, false);
|
||||||
|
yield return ("销售", "t_customer", "Salesman", FormulaValueType.String, false);
|
||||||
|
yield return ("客服", "t_customer", "CustomerService", FormulaValueType.String, false);
|
||||||
|
yield return ("客户组", "t_customer", "CustomerGroup", FormulaValueType.String, false);
|
||||||
|
|
||||||
|
// virtual
|
||||||
|
yield return ("单件最大长度", "_virtual", "PieceMaxLength", FormulaValueType.Number, true);
|
||||||
|
yield return ("单件最大宽度", "_virtual", "PieceMaxWidth", FormulaValueType.Number, true);
|
||||||
|
yield return ("单件最大高度", "_virtual", "PieceMaxHeight", FormulaValueType.Number, true);
|
||||||
|
yield return ("单件长宽高之和", "_virtual", "PieceSumLwh", FormulaValueType.Number, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
|
public static class AutoTriggerRuleResolver
|
||||||
|
{
|
||||||
|
public static int GetPriority(AutoTrigger trigger)
|
||||||
|
{
|
||||||
|
if (trigger.Priority > 0)
|
||||||
|
{
|
||||||
|
return trigger.Priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AutoTriggerConditionParser.Parse(trigger.TriggerCondition).Priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? GetRuleName(AutoTrigger trigger)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(trigger.RuleName))
|
||||||
|
{
|
||||||
|
return trigger.RuleName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AutoTriggerConditionParser.Parse(trigger.TriggerCondition).RuleName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,10 +12,13 @@ namespace Oms.Application.Services.Orders.Issues;
|
|||||||
public class AutoTriggerRuntimeService(
|
public class AutoTriggerRuntimeService(
|
||||||
IAutoTriggerRepository autoTriggerRepository,
|
IAutoTriggerRepository autoTriggerRepository,
|
||||||
IOrderRepository orderRepository,
|
IOrderRepository orderRepository,
|
||||||
|
OrderAutoTriggerService orderAutoTriggerService,
|
||||||
OrderExceptionWorkflowService exceptionWorkflowService,
|
OrderExceptionWorkflowService exceptionWorkflowService,
|
||||||
OrderIssueNotificationService issueNotificationService,
|
OrderIssueNotificationService issueNotificationService,
|
||||||
ILogger<AutoTriggerRuntimeService> logger) : IScopedDependency
|
ILogger<AutoTriggerRuntimeService> logger) : IScopedDependency
|
||||||
{
|
{
|
||||||
|
private const string InterceptOperation = "拦截";
|
||||||
|
|
||||||
public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId)
|
public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId)
|
||||||
{
|
{
|
||||||
if (order.Id <= 0)
|
if (order.Id <= 0)
|
||||||
@ -23,15 +26,25 @@ public class AutoTriggerRuntimeService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var context = await orderAutoTriggerService.BuildPostCommitContextAsync(order);
|
||||||
var triggers = await autoTriggerRepository.GetListAsync(x => x.Status == EnableStatus.Enabled);
|
var triggers = await autoTriggerRepository.GetListAsync(x => x.Status == EnableStatus.Enabled);
|
||||||
foreach (var trigger in triggers)
|
var orderedTriggers = triggers
|
||||||
|
.Where(x => AutoTriggerTimingCodes.Matches(x.TriggerTiming, timing))
|
||||||
|
.Select(x => (Trigger: x, Priority: AutoTriggerRuleResolver.GetPriority(x)))
|
||||||
|
.OrderBy(x => x.Priority)
|
||||||
|
.ThenBy(x => x.Trigger.Id)
|
||||||
|
.Select(x => x.Trigger)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var trigger in orderedTriggers)
|
||||||
{
|
{
|
||||||
if (!AutoTriggerTimingCodes.Matches(trigger.TriggerTiming, timing))
|
if (timing == AutoTriggerTiming.OrderCreate
|
||||||
|
&& string.Equals(trigger.Operation?.Trim(), InterceptOperation, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!AutoTriggerConditionMatcher.Matches(order, trigger.TriggerCondition))
|
if (!AutoTriggerConditionMatcher.Matches(context, trigger.TriggerCondition))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -64,7 +77,7 @@ public class AutoTriggerRuntimeService(
|
|||||||
int operatorUserId)
|
int operatorUserId)
|
||||||
{
|
{
|
||||||
var operation = trigger.Operation?.Trim();
|
var operation = trigger.Operation?.Trim();
|
||||||
if (string.Equals(operation, "拦截", StringComparison.Ordinal))
|
if (string.Equals(operation, InterceptOperation, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
if (order.IsIntercept == 0)
|
if (order.IsIntercept == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -23,7 +23,7 @@ public static class AutoTriggerTimingCodes
|
|||||||
var text = triggerTiming.Trim();
|
var text = triggerTiming.Trim();
|
||||||
return timing switch
|
return timing switch
|
||||||
{
|
{
|
||||||
AutoTriggerTiming.OrderCreate => ContainsAny(text, "下单", "order", "create", "parcel", "预报"),
|
AutoTriggerTiming.OrderCreate => ContainsAny(text, "下单", "order", "create", "parcel", "预报", "forecast"),
|
||||||
AutoTriggerTiming.Inbound => ContainsAny(text, "入库", "inbound", "receive", "收货"),
|
AutoTriggerTiming.Inbound => ContainsAny(text, "入库", "inbound", "receive", "收货"),
|
||||||
AutoTriggerTiming.Outbound => ContainsAny(text, "出库", "outbound", "ship", "发货"),
|
AutoTriggerTiming.Outbound => ContainsAny(text, "出库", "outbound", "ship", "发货"),
|
||||||
_ => false
|
_ => false
|
||||||
|
|||||||
@ -0,0 +1,488 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
|
public static class ChineseFormulaEvaluator
|
||||||
|
{
|
||||||
|
private const string ContainsKeyword = "包含";
|
||||||
|
|
||||||
|
public static bool TryEvaluate(string? expression, OrderRuleEvaluationContext context, out bool result)
|
||||||
|
{
|
||||||
|
result = false;
|
||||||
|
var text = expression?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
result = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parser = new Parser(text, context);
|
||||||
|
result = parser.ParseExpression();
|
||||||
|
parser.ExpectEnd();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormulaParseException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FormulaParseException(string message) : Exception(message);
|
||||||
|
|
||||||
|
private enum TokenKind
|
||||||
|
{
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Label,
|
||||||
|
And,
|
||||||
|
Or,
|
||||||
|
Not,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Multiply,
|
||||||
|
Divide,
|
||||||
|
Greater,
|
||||||
|
GreaterEqual,
|
||||||
|
Less,
|
||||||
|
LessEqual,
|
||||||
|
Equal,
|
||||||
|
NotEqual,
|
||||||
|
LeftParen,
|
||||||
|
RightParen,
|
||||||
|
Contains,
|
||||||
|
End
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Token(TokenKind Kind, string Text, decimal Number = 0);
|
||||||
|
|
||||||
|
private sealed class Lexer(string source)
|
||||||
|
{
|
||||||
|
private int _pos;
|
||||||
|
|
||||||
|
public Token Next()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (_pos >= source.Length)
|
||||||
|
{
|
||||||
|
return new Token(TokenKind.End, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ch = source[_pos];
|
||||||
|
if (ch is >= '0' and <= '9' || (ch == '.' && _pos + 1 < source.Length && source[_pos + 1] is >= '0' and <= '9'))
|
||||||
|
{
|
||||||
|
return ReadNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch == '\'')
|
||||||
|
{
|
||||||
|
return ReadString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryReadOperator(out var opToken))
|
||||||
|
{
|
||||||
|
return opToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryReadLabel(out var labelToken))
|
||||||
|
{
|
||||||
|
return labelToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FormulaParseException($"无法识别的字符:{source[_pos]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Token Peek()
|
||||||
|
{
|
||||||
|
var saved = _pos;
|
||||||
|
var token = Next();
|
||||||
|
_pos = saved;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SkipWhitespace()
|
||||||
|
{
|
||||||
|
while (_pos < source.Length && char.IsWhiteSpace(source[_pos]))
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token ReadNumber()
|
||||||
|
{
|
||||||
|
var start = _pos;
|
||||||
|
while (_pos < source.Length && (char.IsDigit(source[_pos]) || source[_pos] == '.'))
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = source[start.._pos];
|
||||||
|
if (!decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var number))
|
||||||
|
{
|
||||||
|
throw new FormulaParseException($"无效数字:{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Token(TokenKind.Number, text, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Token ReadString()
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
while (_pos < source.Length)
|
||||||
|
{
|
||||||
|
var ch = source[_pos++];
|
||||||
|
if (ch == '\'')
|
||||||
|
{
|
||||||
|
if (_pos < source.Length && source[_pos] == '\'')
|
||||||
|
{
|
||||||
|
sb.Append('\'');
|
||||||
|
_pos++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Token(TokenKind.String, sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FormulaParseException("字符串未闭合");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReadOperator(out Token token)
|
||||||
|
{
|
||||||
|
token = default;
|
||||||
|
if (_pos + 1 < source.Length)
|
||||||
|
{
|
||||||
|
var two = source.Substring(_pos, 2);
|
||||||
|
token = two switch
|
||||||
|
{
|
||||||
|
"&&" => new Token(TokenKind.And, two),
|
||||||
|
"||" => new Token(TokenKind.Or, two),
|
||||||
|
">=" => new Token(TokenKind.GreaterEqual, two),
|
||||||
|
"<=" => new Token(TokenKind.LessEqual, two),
|
||||||
|
"==" => new Token(TokenKind.Equal, two),
|
||||||
|
"!=" => new Token(TokenKind.NotEqual, two),
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
if (token.Kind != default)
|
||||||
|
{
|
||||||
|
_pos += 2;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ch = source[_pos];
|
||||||
|
token = ch switch
|
||||||
|
{
|
||||||
|
'+' => new Token(TokenKind.Plus, "+"),
|
||||||
|
'-' => new Token(TokenKind.Minus, "-"),
|
||||||
|
'*' => new Token(TokenKind.Multiply, "*"),
|
||||||
|
'/' => new Token(TokenKind.Divide, "/"),
|
||||||
|
'>' => new Token(TokenKind.Greater, ">"),
|
||||||
|
'<' => new Token(TokenKind.Less, "<"),
|
||||||
|
'!' => new Token(TokenKind.Not, "!"),
|
||||||
|
'(' => new Token(TokenKind.LeftParen, "("),
|
||||||
|
')' => new Token(TokenKind.RightParen, ")"),
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
if (token.Kind == default)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pos++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReadLabel(out Token token)
|
||||||
|
{
|
||||||
|
token = default;
|
||||||
|
foreach (var label in AutoTriggerFormulaMeta.LabelsByLengthDesc)
|
||||||
|
{
|
||||||
|
if (!source.AsSpan(_pos).StartsWith(label, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pos += label.Length;
|
||||||
|
SkipWhitespace();
|
||||||
|
if (_pos + ContainsKeyword.Length <= source.Length
|
||||||
|
&& source.AsSpan(_pos, ContainsKeyword.Length).SequenceEqual(ContainsKeyword.AsSpan()))
|
||||||
|
{
|
||||||
|
_pos += ContainsKeyword.Length;
|
||||||
|
SkipWhitespace();
|
||||||
|
if (_pos < source.Length && source[_pos] == '(')
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
token = new Token(TokenKind.Contains, label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pos + 2 <= source.Length && source.AsSpan(_pos, 2).SequenceEqual("()".AsSpan()))
|
||||||
|
{
|
||||||
|
_pos += 2;
|
||||||
|
token = new Token(TokenKind.Label, label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pos -= label.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SavePosition() => _pos;
|
||||||
|
|
||||||
|
public void RestorePosition(int pos) => _pos = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Parser
|
||||||
|
{
|
||||||
|
private readonly Lexer _lexer;
|
||||||
|
private readonly OrderRuleEvaluationContext _context;
|
||||||
|
private Token _current;
|
||||||
|
|
||||||
|
public Parser(string source, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
_lexer = new Lexer(source);
|
||||||
|
_context = context;
|
||||||
|
_current = _lexer.Next();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ParseExpression() => ParseOr();
|
||||||
|
|
||||||
|
public void ExpectEnd()
|
||||||
|
{
|
||||||
|
if (_current.Kind != TokenKind.End)
|
||||||
|
{
|
||||||
|
throw new FormulaParseException($"公式未预期结束:{_current.Text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ParseOr()
|
||||||
|
{
|
||||||
|
var value = ParseAnd();
|
||||||
|
while (_current.Kind == TokenKind.Or)
|
||||||
|
{
|
||||||
|
Advance();
|
||||||
|
value = value || ParseAnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ParseAnd()
|
||||||
|
{
|
||||||
|
var value = ParseNot();
|
||||||
|
while (_current.Kind == TokenKind.And)
|
||||||
|
{
|
||||||
|
Advance();
|
||||||
|
value = value && ParseNot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ParseNot()
|
||||||
|
{
|
||||||
|
if (_current.Kind == TokenKind.Not)
|
||||||
|
{
|
||||||
|
Advance();
|
||||||
|
return !ParseNot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseComparison();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ParseComparison()
|
||||||
|
{
|
||||||
|
if (_current.Kind is TokenKind.Contains)
|
||||||
|
{
|
||||||
|
return ParseContains();
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = ParseAdditive();
|
||||||
|
if (_current.Kind is TokenKind.Greater or TokenKind.GreaterEqual or TokenKind.Less or TokenKind.LessEqual or TokenKind.Equal or TokenKind.NotEqual)
|
||||||
|
{
|
||||||
|
var op = _current.Kind;
|
||||||
|
Advance();
|
||||||
|
var right = ParseAdditive();
|
||||||
|
return Compare(left, op, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
return left != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ParseContains()
|
||||||
|
{
|
||||||
|
var label = _current.Text;
|
||||||
|
Advance();
|
||||||
|
if (_current.Kind != TokenKind.String)
|
||||||
|
{
|
||||||
|
throw new FormulaParseException("包含函数缺少字符串参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
var needle = _current.Text;
|
||||||
|
Advance();
|
||||||
|
if (_current.Kind != TokenKind.RightParen)
|
||||||
|
{
|
||||||
|
throw new FormulaParseException("包含函数缺少右括号");
|
||||||
|
}
|
||||||
|
|
||||||
|
Advance();
|
||||||
|
if (!AutoTriggerFormulaMeta.TryResolve(label, out var fieldRef) || fieldRef == null)
|
||||||
|
{
|
||||||
|
throw new FormulaParseException($"未知字段:{label}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return OrderFormulaFieldResolver.StringContains(fieldRef, _context, needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decimal ParseAdditive()
|
||||||
|
{
|
||||||
|
var value = ParseMultiplicative();
|
||||||
|
while (_current.Kind is TokenKind.Plus or TokenKind.Minus)
|
||||||
|
{
|
||||||
|
var op = _current.Kind;
|
||||||
|
Advance();
|
||||||
|
var right = ParseMultiplicative();
|
||||||
|
value = op == TokenKind.Plus ? value + right : value - right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decimal ParseMultiplicative()
|
||||||
|
{
|
||||||
|
var value = ParseUnary();
|
||||||
|
while (_current.Kind is TokenKind.Multiply or TokenKind.Divide)
|
||||||
|
{
|
||||||
|
var op = _current.Kind;
|
||||||
|
Advance();
|
||||||
|
var right = ParseUnary();
|
||||||
|
value = op == TokenKind.Multiply ? value * right : right == 0 ? 0 : value / right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decimal ParseUnary()
|
||||||
|
{
|
||||||
|
if (_current.Kind == TokenKind.Minus)
|
||||||
|
{
|
||||||
|
Advance();
|
||||||
|
return -ParseUnary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsePrimary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private decimal ParsePrimary()
|
||||||
|
{
|
||||||
|
switch (_current.Kind)
|
||||||
|
{
|
||||||
|
case TokenKind.Number:
|
||||||
|
{
|
||||||
|
var number = _current.Number;
|
||||||
|
Advance();
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
case TokenKind.Label:
|
||||||
|
{
|
||||||
|
var label = _current.Text;
|
||||||
|
Advance();
|
||||||
|
if (!AutoTriggerFormulaMeta.TryResolve(label, out var fieldRef) || fieldRef == null)
|
||||||
|
{
|
||||||
|
throw new FormulaParseException($"未知字段:{label}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return OrderFormulaFieldResolver.GetNumericValue(fieldRef, _context);
|
||||||
|
}
|
||||||
|
case TokenKind.LeftParen:
|
||||||
|
{
|
||||||
|
Advance();
|
||||||
|
decimal value;
|
||||||
|
if (LooksLikeBooleanGroup())
|
||||||
|
{
|
||||||
|
var inside = ParseOr();
|
||||||
|
if (_current.Kind != TokenKind.RightParen)
|
||||||
|
{
|
||||||
|
throw new FormulaParseException("缺少右括号");
|
||||||
|
}
|
||||||
|
|
||||||
|
Advance();
|
||||||
|
value = inside ? 1 : 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
value = ParseAdditive();
|
||||||
|
if (_current.Kind != TokenKind.RightParen)
|
||||||
|
{
|
||||||
|
throw new FormulaParseException("缺少右括号");
|
||||||
|
}
|
||||||
|
|
||||||
|
Advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new FormulaParseException($"无法解析:{_current.Text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Compare(decimal left, TokenKind op, decimal right) =>
|
||||||
|
op switch
|
||||||
|
{
|
||||||
|
TokenKind.Greater => left > right,
|
||||||
|
TokenKind.GreaterEqual => left >= right,
|
||||||
|
TokenKind.Less => left < right,
|
||||||
|
TokenKind.LessEqual => left <= right,
|
||||||
|
TokenKind.Equal => left == right,
|
||||||
|
TokenKind.NotEqual => left != right,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
private bool LooksLikeBooleanGroup()
|
||||||
|
{
|
||||||
|
var savedPos = _lexer.SavePosition();
|
||||||
|
var depth = 1;
|
||||||
|
while (depth > 0)
|
||||||
|
{
|
||||||
|
var token = _lexer.Next();
|
||||||
|
if (token.Kind == TokenKind.End)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.Kind == TokenKind.LeftParen)
|
||||||
|
{
|
||||||
|
depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.Kind == TokenKind.RightParen)
|
||||||
|
{
|
||||||
|
depth--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth == 1 && token.Kind is TokenKind.And or TokenKind.Or)
|
||||||
|
{
|
||||||
|
_lexer.RestorePosition(savedPos);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lexer.RestorePosition(savedPos);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Advance() => _current = _lexer.Next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
using Fake.Application;
|
||||||
|
using Fake.DependencyInjection;
|
||||||
|
using Oms.Application.Dtos.Channels;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
using Oms.Domain.OrdersAggregate.Repositories;
|
||||||
|
using Oms.Domain.Shared.Enums;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
|
public class OrderAutoTriggerService(
|
||||||
|
IAutoTriggerRepository autoTriggerRepository,
|
||||||
|
IOrderRepository orderRepository,
|
||||||
|
ICustomerRepository customerRepository) : IScopedDependency
|
||||||
|
{
|
||||||
|
private const string InterceptOperation = "拦截";
|
||||||
|
|
||||||
|
public async Task<AutoTriggerTestOutput> TestFormulaAsync(
|
||||||
|
AutoTriggerTestInput input,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var expression = ResolveTestExpression(input);
|
||||||
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
|
{
|
||||||
|
throw new BusinessException("请提供公式表达式");
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderRuleEvaluationContext context;
|
||||||
|
string contextType;
|
||||||
|
if (input.OrderId.HasValue && input.OrderId.Value > 0)
|
||||||
|
{
|
||||||
|
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == input.OrderId.Value, cancellationToken);
|
||||||
|
if (order == null)
|
||||||
|
{
|
||||||
|
throw new BusinessException("订单不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
context = await BuildPostCommitContextAsync(order, cancellationToken);
|
||||||
|
contextType = "order";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context = await BuildSampleContextAsync(input.Sample, cancellationToken);
|
||||||
|
contextType = "sample";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ChineseFormulaEvaluator.TryEvaluate(expression, context, out var matched))
|
||||||
|
{
|
||||||
|
return new AutoTriggerTestOutput
|
||||||
|
{
|
||||||
|
Matched = false,
|
||||||
|
Expression = expression,
|
||||||
|
ContextType = contextType,
|
||||||
|
Error = "公式解析失败,请检查语法与中文字段名"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AutoTriggerTestOutput
|
||||||
|
{
|
||||||
|
Matched = matched,
|
||||||
|
Expression = expression,
|
||||||
|
ContextType = contextType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EvaluatePreCommitAsync(
|
||||||
|
OrderParcelCreateInput input,
|
||||||
|
Customer customer,
|
||||||
|
string consigneeCountryCode,
|
||||||
|
string? consigneeCountryName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var triggers = await LoadMatchedTriggersAsync(
|
||||||
|
AutoTriggerTiming.OrderCreate,
|
||||||
|
InterceptOperation,
|
||||||
|
cancellationToken);
|
||||||
|
if (triggers.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = OrderRuleEvaluationContext.FromPreCommit(input, customer, consigneeCountryCode, consigneeCountryName);
|
||||||
|
foreach (var trigger in triggers)
|
||||||
|
{
|
||||||
|
if (!AutoTriggerConditionMatcher.Matches(context, trigger.TriggerCondition))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = string.IsNullOrWhiteSpace(trigger.PromptMessage)
|
||||||
|
? "订单命中自动拦截规则"
|
||||||
|
: trigger.PromptMessage.Trim();
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OrderRuleEvaluationContext> BuildPostCommitContextAsync(
|
||||||
|
Order order,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var orderIds = new[] { order.Id };
|
||||||
|
var customer = order.CustomerId.HasValue
|
||||||
|
? await customerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value, cancellationToken)
|
||||||
|
: null;
|
||||||
|
var recipients = await orderRepository.GetRecipientsByOrderIdsAsync(orderIds, cancellationToken);
|
||||||
|
var senders = await orderRepository.GetSendersByOrderIdsAsync(orderIds, cancellationToken);
|
||||||
|
var declarations = await orderRepository.GetDeclarationsByOrderIdsAsync(orderIds, cancellationToken);
|
||||||
|
var weights = await orderRepository.GetWeightsByOrderIdsAsync(orderIds, cancellationToken);
|
||||||
|
var fees = await orderRepository.GetFeesByOrderIdsAsync(orderIds, cancellationToken);
|
||||||
|
|
||||||
|
return OrderRuleEvaluationContext.FromOrder(
|
||||||
|
order,
|
||||||
|
customer,
|
||||||
|
recipients.FirstOrDefault(x => x.OrderId == order.Id),
|
||||||
|
senders.FirstOrDefault(x => x.OrderId == order.Id),
|
||||||
|
declarations.Where(x => x.OrderId == order.Id).ToList(),
|
||||||
|
weights.Where(x => x.OrderId == order.Id).ToList(),
|
||||||
|
fees.Where(x => x.OrderId == order.Id).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<AutoTrigger>> LoadMatchedTriggersAsync(
|
||||||
|
AutoTriggerTiming timing,
|
||||||
|
string? operation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var triggers = await autoTriggerRepository.GetEnabledListAsync(cancellationToken);
|
||||||
|
return triggers
|
||||||
|
.Where(x => AutoTriggerTimingCodes.Matches(x.TriggerTiming, timing))
|
||||||
|
.Where(x => string.IsNullOrWhiteSpace(operation)
|
||||||
|
|| string.Equals(x.Operation?.Trim(), operation, StringComparison.Ordinal))
|
||||||
|
.Select(x => (Trigger: x, Priority: AutoTriggerRuleResolver.GetPriority(x)))
|
||||||
|
.OrderBy(x => x.Priority)
|
||||||
|
.ThenBy(x => x.Trigger.Id)
|
||||||
|
.Select(x => x.Trigger)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTestExpression(AutoTriggerTestInput input)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(input.Expression))
|
||||||
|
{
|
||||||
|
return input.Expression.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = AutoTriggerConditionParser.Parse(input.TriggerCondition);
|
||||||
|
return parsed.Expression?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrderRuleEvaluationContext> BuildSampleContextAsync(
|
||||||
|
AutoTriggerTestSampleInput? sample,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
sample ??= new AutoTriggerTestSampleInput();
|
||||||
|
Customer? customer = null;
|
||||||
|
if (sample.CustomerId.HasValue && sample.CustomerId.Value > 0)
|
||||||
|
{
|
||||||
|
customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == sample.CustomerId.Value, cancellationToken);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(sample.CustomerName))
|
||||||
|
{
|
||||||
|
customer = new Customer { CustomerName = sample.CustomerName.Trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = new OrderParcelCreateInput
|
||||||
|
{
|
||||||
|
ClientNo = sample.ClientNo ?? "TEST-CLIENT-NO",
|
||||||
|
ProductCode = sample.ProductCode,
|
||||||
|
ChannelCode = sample.ChannelCode,
|
||||||
|
CustomerId = sample.CustomerId ?? customer?.Id,
|
||||||
|
Length = sample.Length,
|
||||||
|
Width = sample.Width,
|
||||||
|
Height = sample.Height,
|
||||||
|
ActualWeight = sample.ActualWeight,
|
||||||
|
Receiver = new OrderParcelReceiverInput()
|
||||||
|
};
|
||||||
|
|
||||||
|
return OrderRuleEvaluationContext.FromPreCommit(
|
||||||
|
input,
|
||||||
|
customer ?? new Customer { CustomerName = sample.CustomerName ?? string.Empty },
|
||||||
|
sample.ConsigneeCountry ?? string.Empty,
|
||||||
|
sample.ConsigneeCountryName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,302 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
|
public static class OrderFormulaFieldResolver
|
||||||
|
{
|
||||||
|
public static decimal GetNumericValue(FormulaFieldRef fieldRef, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
var value = GetRawValue(fieldRef, context);
|
||||||
|
return ToDecimal(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool StringContains(FormulaFieldRef fieldRef, OrderRuleEvaluationContext context, string needle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(needle))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldRef.Table == "t_order_declaration")
|
||||||
|
{
|
||||||
|
return context.Declarations.Any(d => ContainsValue(GetDeclarationValue(d, fieldRef.Field), needle))
|
||||||
|
|| context.CreateInput?.Items.Any(i => ContainsValue(GetDeclarationInputValue(i, fieldRef.Field), needle)) == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldRef.Table == "t_order_fee")
|
||||||
|
{
|
||||||
|
return context.Fees.Any(f => ContainsValue(GetPropertyValue(f, fieldRef.Field), needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = GetRawValue(fieldRef, context);
|
||||||
|
return ContainsValue(raw, needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? GetRawValue(FormulaFieldRef fieldRef, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
if (fieldRef.Virtual)
|
||||||
|
{
|
||||||
|
return ResolveVirtual(fieldRef.Field, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldRef.Table switch
|
||||||
|
{
|
||||||
|
"t_order" => ResolveOrderField(fieldRef.Field, context),
|
||||||
|
"t_order_recipient" => ResolveRecipientField(fieldRef.Field, context),
|
||||||
|
"t_order_sender" => ResolveSenderField(fieldRef.Field, context),
|
||||||
|
"t_order_declaration" => ResolveDeclarationField(fieldRef.Field, context),
|
||||||
|
"t_order_weight" => ResolveWeightField(fieldRef.Field, context),
|
||||||
|
"t_order_fee" => ResolveFeeField(fieldRef.Field, context),
|
||||||
|
"t_customer" => GetPropertyValue(context.Customer, fieldRef.Field),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ResolveVirtual(string field, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
var (length, width, height) = GetPieceDimensions(context);
|
||||||
|
return field switch
|
||||||
|
{
|
||||||
|
"PieceMaxLength" => length,
|
||||||
|
"PieceMaxWidth" => width,
|
||||||
|
"PieceMaxHeight" => height,
|
||||||
|
"PieceSumLwh" => length + width + height,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (decimal Length, decimal Width, decimal Height) GetPieceDimensions(OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
var forecast = GetForecastWeight(context);
|
||||||
|
if (forecast != null)
|
||||||
|
{
|
||||||
|
return (forecast.Length ?? 0, forecast.Width ?? 0, forecast.Height ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = context.CreateInput;
|
||||||
|
if (input != null)
|
||||||
|
{
|
||||||
|
return (input.Length ?? 0, input.Width ?? 0, input.Height ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderWeight? GetForecastWeight(OrderRuleEvaluationContext context) =>
|
||||||
|
context.Weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Forecast)
|
||||||
|
?? context.Weights.FirstOrDefault();
|
||||||
|
|
||||||
|
private static object? ResolveOrderField(string field, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
if (context.Order != null)
|
||||||
|
{
|
||||||
|
return GetPropertyValue(context.Order, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = context.CreateInput;
|
||||||
|
if (input == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return field switch
|
||||||
|
{
|
||||||
|
"RequestId" => input.RequestId,
|
||||||
|
"ClientNo" => input.ClientNo,
|
||||||
|
"TrackNo" => input.TrackNo,
|
||||||
|
"ServiceNo" => input.ServiceNo,
|
||||||
|
"ProductCode" => input.ProductCode,
|
||||||
|
"ChannelCode" => input.ChannelCode,
|
||||||
|
"ConsigneeCountry" => context.ConsigneeCountryCode,
|
||||||
|
"ConsigneeCountryName" => context.ConsigneeCountryName,
|
||||||
|
"CargoAttribute" => input.CargoAttribute,
|
||||||
|
"ExportCustomsMode" => input.ExportCustomsMode,
|
||||||
|
"WarehouseId" => input.WarehouseId,
|
||||||
|
"CustomerId" => input.CustomerId,
|
||||||
|
"IsCod" => input.IsCod ? (byte)1 : (byte)0,
|
||||||
|
"CodCurrency" => input.CodCurrency,
|
||||||
|
"CodSum" => input.CodSum,
|
||||||
|
"DeclaredCurrency" => input.DeclaredCurrency,
|
||||||
|
"DeclaredSum" => input.DeclaredSum,
|
||||||
|
"Quantity" => input.Quantity,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ResolveRecipientField(string field, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
if (context.Recipient != null)
|
||||||
|
{
|
||||||
|
return GetPropertyValue(context.Recipient, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
var receiver = context.CreateInput?.Receiver;
|
||||||
|
if (receiver == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return field switch
|
||||||
|
{
|
||||||
|
"RecipientsName" => receiver.RecipientsName,
|
||||||
|
"CompanyName" => receiver.CompanyName,
|
||||||
|
"Phone" => receiver.Phone,
|
||||||
|
"TelePhone" => receiver.TelePhone,
|
||||||
|
"Email" => receiver.Email,
|
||||||
|
"Province" => receiver.Province,
|
||||||
|
"City" => receiver.City,
|
||||||
|
"StreetNumber" => receiver.StreetNumber,
|
||||||
|
"StreetAddress1" => receiver.StreetAddress1,
|
||||||
|
"StreetAddress2" => receiver.StreetAddress2,
|
||||||
|
"StreetAddress3" => receiver.StreetAddress3,
|
||||||
|
"ZipCode" => receiver.ZipCode,
|
||||||
|
"TaxType" => receiver.TaxType,
|
||||||
|
"TaxNo" => receiver.TaxNo,
|
||||||
|
"CertificateType" => receiver.CertificateType,
|
||||||
|
"CertificateNo" => receiver.CertificateNo,
|
||||||
|
"Eori" => receiver.Eori,
|
||||||
|
"Vat" => receiver.Vat,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ResolveSenderField(string field, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
if (context.Sender != null)
|
||||||
|
{
|
||||||
|
return GetPropertyValue(context.Sender, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sender = context.CreateInput?.Sender;
|
||||||
|
if (sender == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return field switch
|
||||||
|
{
|
||||||
|
"SenderName" => sender.SenderName,
|
||||||
|
"Company" => sender.Company,
|
||||||
|
"Phone" => sender.Phone,
|
||||||
|
"TelePhone" => sender.TelePhone,
|
||||||
|
"Email" => sender.Email,
|
||||||
|
"Province" => sender.Province,
|
||||||
|
"City" => sender.City,
|
||||||
|
"ZipCode" => sender.ZipCode,
|
||||||
|
"StreetAddress1" => sender.StreetAddress1,
|
||||||
|
"StreetAddress2" => sender.StreetAddress2,
|
||||||
|
"StreetAddress3" => sender.StreetAddress3,
|
||||||
|
"HouseNumber" => sender.HouseNumber,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ResolveDeclarationField(string field, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
var declaration = context.Declarations.FirstOrDefault();
|
||||||
|
if (declaration != null)
|
||||||
|
{
|
||||||
|
return GetDeclarationValue(declaration, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = context.CreateInput?.Items.FirstOrDefault();
|
||||||
|
return item == null ? null : GetDeclarationInputValue(item, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ResolveWeightField(string field, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
var weight = GetForecastWeight(context);
|
||||||
|
if (weight != null)
|
||||||
|
{
|
||||||
|
return GetPropertyValue(weight, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = context.CreateInput;
|
||||||
|
if (input == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return field switch
|
||||||
|
{
|
||||||
|
"Length" => input.Length,
|
||||||
|
"Width" => input.Width,
|
||||||
|
"Height" => input.Height,
|
||||||
|
"ActualWeight" => input.ActualWeight,
|
||||||
|
"VolumetricWeight" => input.VolumetricWeight,
|
||||||
|
"ChargeableWeight" => input.ChargeableWeight,
|
||||||
|
"Cubic" => input.Cubic,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ResolveFeeField(string field, OrderRuleEvaluationContext context)
|
||||||
|
{
|
||||||
|
var fee = context.Fees.FirstOrDefault();
|
||||||
|
return fee == null ? null : GetPropertyValue(fee, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? GetDeclarationValue(OrderDeclaration declaration, string field) =>
|
||||||
|
field switch
|
||||||
|
{
|
||||||
|
"HsCode" => declaration.HsCode ?? declaration.ImHsCode ?? declaration.ExHsCode,
|
||||||
|
_ => GetPropertyValue(declaration, field)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static object? GetDeclarationInputValue(OrderParcelItemInput item, string field) =>
|
||||||
|
field switch
|
||||||
|
{
|
||||||
|
"HsCode" => item.ImHsCode ?? item.ExHsCode,
|
||||||
|
"IsMagnetic" => item.IsMagnetic ? (byte)1 : (byte)0,
|
||||||
|
_ => GetPropertyValue(item, field)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static object? GetPropertyValue(object? target, string fieldName)
|
||||||
|
{
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prop = target.GetType().GetProperty(fieldName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||||
|
return prop?.GetValue(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ToDecimal(object? value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
bool b => b ? 1m : 0m,
|
||||||
|
byte bt => bt,
|
||||||
|
sbyte sb => sb,
|
||||||
|
short s => s,
|
||||||
|
ushort us => us,
|
||||||
|
int i => i,
|
||||||
|
uint ui => ui,
|
||||||
|
long l => l,
|
||||||
|
ulong ul => ul,
|
||||||
|
float f => (decimal)f,
|
||||||
|
double d => (decimal)d,
|
||||||
|
decimal m => m,
|
||||||
|
DateTime => 0,
|
||||||
|
Enum e => Convert.ToDecimal(e, CultureInfo.InvariantCulture),
|
||||||
|
_ => decimal.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsValue(object? source, string needle) =>
|
||||||
|
!string.IsNullOrWhiteSpace(Convert.ToString(source, CultureInfo.InvariantCulture))
|
||||||
|
&& Convert.ToString(source, CultureInfo.InvariantCulture)!
|
||||||
|
.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Orders.Issues;
|
||||||
|
|
||||||
|
public sealed class OrderRuleEvaluationContext
|
||||||
|
{
|
||||||
|
public Order? Order { get; init; }
|
||||||
|
|
||||||
|
public OrderParcelCreateInput? CreateInput { get; init; }
|
||||||
|
|
||||||
|
public Customer? Customer { get; init; }
|
||||||
|
|
||||||
|
public OrderRecipient? Recipient { get; init; }
|
||||||
|
|
||||||
|
public OrderSender? Sender { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<OrderDeclaration> Declarations { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<OrderWeight> Weights { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<OrderFee> Fees { get; init; } = [];
|
||||||
|
|
||||||
|
public string? ConsigneeCountryCode { get; init; }
|
||||||
|
|
||||||
|
public string? ConsigneeCountryName { get; init; }
|
||||||
|
|
||||||
|
public static OrderRuleEvaluationContext FromPreCommit(
|
||||||
|
OrderParcelCreateInput input,
|
||||||
|
Customer customer,
|
||||||
|
string consigneeCountryCode,
|
||||||
|
string? consigneeCountryName)
|
||||||
|
{
|
||||||
|
return new OrderRuleEvaluationContext
|
||||||
|
{
|
||||||
|
CreateInput = input,
|
||||||
|
Customer = customer,
|
||||||
|
ConsigneeCountryCode = consigneeCountryCode,
|
||||||
|
ConsigneeCountryName = consigneeCountryName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrderRuleEvaluationContext FromOrder(
|
||||||
|
Order order,
|
||||||
|
Customer? customer,
|
||||||
|
OrderRecipient? recipient,
|
||||||
|
OrderSender? sender,
|
||||||
|
IReadOnlyList<OrderDeclaration>? declarations,
|
||||||
|
IReadOnlyList<OrderWeight>? weights,
|
||||||
|
IReadOnlyList<OrderFee>? fees)
|
||||||
|
{
|
||||||
|
return new OrderRuleEvaluationContext
|
||||||
|
{
|
||||||
|
Order = order,
|
||||||
|
Customer = customer,
|
||||||
|
Recipient = recipient,
|
||||||
|
Sender = sender,
|
||||||
|
Declarations = declarations ?? [],
|
||||||
|
Weights = weights ?? [],
|
||||||
|
Fees = fees ?? [],
|
||||||
|
ConsigneeCountryCode = order.ConsigneeCountry,
|
||||||
|
ConsigneeCountryName = order.ConsigneeCountryName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrderRuleEvaluationContext FromOrderOnly(Order order) =>
|
||||||
|
FromOrder(order, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
@ -37,6 +37,9 @@ public partial class OrderService
|
|||||||
private AutoTriggerRuntimeService AutoTriggerRuntimeService =>
|
private AutoTriggerRuntimeService AutoTriggerRuntimeService =>
|
||||||
LazyServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
|
LazyServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
|
||||||
|
|
||||||
|
private OrderAutoTriggerService OrderAutoTriggerService =>
|
||||||
|
LazyServiceProvider.GetRequiredService<OrderAutoTriggerService>();
|
||||||
|
|
||||||
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(
|
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(
|
||||||
OrderParcelCreateInput input,
|
OrderParcelCreateInput input,
|
||||||
int? operatorUserId = null)
|
int? operatorUserId = null)
|
||||||
@ -78,6 +81,12 @@ public partial class OrderService
|
|||||||
throw new BusinessException("收件地址命中黑名单区域");
|
throw new BusinessException("收件地址命中黑名单区域");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await OrderAutoTriggerService.EvaluatePreCommitAsync(
|
||||||
|
input,
|
||||||
|
sessionCustomer,
|
||||||
|
country.CountryCode,
|
||||||
|
country.ChineseName);
|
||||||
|
|
||||||
var serviceNo = await ResolveServiceNoAsync();
|
var serviceNo = await ResolveServiceNoAsync();
|
||||||
var order = BuildDraftOrderEntity(input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo);
|
var order = BuildDraftOrderEntity(input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo);
|
||||||
order = await OrderRepository.InsertAsync(order);
|
order = await OrderRepository.InsertAsync(order);
|
||||||
|
|||||||
@ -9,8 +9,94 @@ namespace Oms.Application.Services.Orders;
|
|||||||
|
|
||||||
public partial class OrderService
|
public partial class OrderService
|
||||||
{
|
{
|
||||||
|
private const string SalesProductGroupDictType = "sales_product_group";
|
||||||
|
private const string UngroupedProductGroupCode = "_ungrouped";
|
||||||
|
|
||||||
private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute);
|
private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute);
|
||||||
|
|
||||||
|
public async Task<OrderPriceCheckShippingOptionsOutput> BuildPriceCheckShippingOptionsAsync()
|
||||||
|
{
|
||||||
|
var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
|
||||||
|
var sortedProducts = products
|
||||||
|
.OrderBy(x => x.ProductName)
|
||||||
|
.ThenBy(x => x.ProductCode)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var dictRows = await DictRepository.GetListAsync(x => x.DictType == SalesProductGroupDictType);
|
||||||
|
var dictOrder = dictRows
|
||||||
|
.OrderBy(x => x.DictSort)
|
||||||
|
.ThenBy(x => x.DictCode)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var grouped = sortedProducts
|
||||||
|
.GroupBy(
|
||||||
|
x => string.IsNullOrWhiteSpace(x.ProductGroup) ? UngroupedProductGroupCode : x.ProductGroup.Trim(),
|
||||||
|
StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(x => x.Key, x => x.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var groups = new List<OrderPriceCheckShippingGroupOutput>();
|
||||||
|
var consumedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var dict in dictOrder)
|
||||||
|
{
|
||||||
|
var code = dict.DictCode?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(code) || !grouped.TryGetValue(code, out var list) || list.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
consumedCodes.Add(code);
|
||||||
|
groups.Add(new OrderPriceCheckShippingGroupOutput
|
||||||
|
{
|
||||||
|
GroupCode = code,
|
||||||
|
GroupName = string.IsNullOrWhiteSpace(dict.DictName) ? code : dict.DictName.Trim(),
|
||||||
|
Products = list.Select(MapPriceCheckShippingOption).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in grouped.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (pair.Key.Equals(UngroupedProductGroupCode, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| consumedCodes.Contains(pair.Key)
|
||||||
|
|| pair.Value.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.Add(new OrderPriceCheckShippingGroupOutput
|
||||||
|
{
|
||||||
|
GroupCode = pair.Key,
|
||||||
|
GroupName = pair.Key,
|
||||||
|
Products = pair.Value.Select(MapPriceCheckShippingOption).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grouped.TryGetValue(UngroupedProductGroupCode, out var ungrouped) && ungrouped.Count > 0)
|
||||||
|
{
|
||||||
|
groups.Add(new OrderPriceCheckShippingGroupOutput
|
||||||
|
{
|
||||||
|
GroupCode = UngroupedProductGroupCode,
|
||||||
|
GroupName = "未分组",
|
||||||
|
Products = ungrouped.Select(MapPriceCheckShippingOption).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OrderPriceCheckShippingOptionsOutput { Groups = groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderPriceCheckShippingOptionOutput MapPriceCheckShippingOption(SalesProduct product)
|
||||||
|
{
|
||||||
|
var code = product.ProductCode?.Trim() ?? string.Empty;
|
||||||
|
var name = product.ProductName?.Trim() ?? string.Empty;
|
||||||
|
return new OrderPriceCheckShippingOptionOutput
|
||||||
|
{
|
||||||
|
ProductId = product.Id,
|
||||||
|
ProductCode = code,
|
||||||
|
ProductName = name,
|
||||||
|
Label = string.IsNullOrWhiteSpace(name) ? code : $"{name} {code}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
||||||
{
|
{
|
||||||
ValidatePriceCheckInput(input);
|
ValidatePriceCheckInput(input);
|
||||||
|
|||||||
@ -254,6 +254,13 @@ public partial class OrderService : ApplicationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查价运输方式选项(按销售产品分组)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/order/price-check/shipping-options")]
|
||||||
|
public Task<OrderPriceCheckShippingOptionsOutput> GetPriceCheckShippingOptionsAsync() =>
|
||||||
|
BuildPriceCheckShippingOptionsAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行订单运费试算。
|
/// 执行订单运费试算。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -9,6 +9,10 @@ public class AutoTrigger : OmsAuditedAggregateRoot
|
|||||||
{
|
{
|
||||||
public EnableStatus Status { get; set; }
|
public EnableStatus Status { get; set; }
|
||||||
|
|
||||||
|
public string? RuleName { get; set; }
|
||||||
|
|
||||||
|
public int Priority { get; set; } = 100;
|
||||||
|
|
||||||
public string? Operation { get; set; }
|
public string? Operation { get; set; }
|
||||||
|
|
||||||
public string? TriggerTiming { get; set; }
|
public string? TriggerTiming { get; set; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user