From 43563b727f8e386ffd8dac22b3654570f88a815c Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 9 Jul 2026 18:36:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E5=BC=95=E6=93=8E=E4=B8=8E=E6=9F=A5=E4=BB=B7?= =?UTF-8?q?=E8=BF=90=E8=BE=93=E6=96=B9=E5=BC=8F=E5=88=86=E7=BB=84=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现中文公式预报前拦截、规则测试 API 及按销售产品分组返回查价运输方式选项。 Co-authored-by: Cursor --- db.sql | 2 + sql/migrate_t_auto_trigger_rule_meta.sql | 18 + .../Dtos/Channels/AutoTriggerMetaOutput.cs | 32 ++ .../Dtos/Channels/AutoTriggerOutput.cs | 10 + .../Dtos/Channels/AutoTriggerSaveInput.cs | 10 + .../Dtos/Channels/AutoTriggerTestInput.cs | 49 ++ .../Dtos/Channels/AutoTriggerTestOutput.cs | 12 + .../OrderPriceCheckShippingOptionsOutput.cs | 38 ++ .../Services/Channels/AutoTriggerService.cs | 55 +- .../Issues/AutoTriggerConditionMatcher.cs | 48 +- .../Issues/AutoTriggerConditionParser.cs | 101 ++++ .../Orders/Issues/AutoTriggerFormulaMeta.cs | 255 +++++++++ .../Orders/Issues/AutoTriggerRuleResolver.cs | 26 + .../Issues/AutoTriggerRuntimeService.cs | 21 +- .../Orders/Issues/AutoTriggerTimingCodes.cs | 2 +- .../Orders/Issues/ChineseFormulaEvaluator.cs | 488 ++++++++++++++++++ .../Orders/Issues/OrderAutoTriggerService.cs | 187 +++++++ .../Issues/OrderFormulaFieldResolver.cs | 302 +++++++++++ .../Issues/OrderRuleEvaluationContext.cs | 70 +++ .../Orders/OrderService.ParcelBilling.cs | 9 + .../Orders/OrderService.ParcelQuote.cs | 86 +++ .../Services/Orders/OrderService.cs | 7 + .../ChannelsAggregate/Entities/AutoTrigger.cs | 4 + 23 files changed, 1808 insertions(+), 24 deletions(-) create mode 100644 sql/migrate_t_auto_trigger_rule_meta.sql create mode 100644 src/Oms.Application/Dtos/Channels/AutoTriggerMetaOutput.cs create mode 100644 src/Oms.Application/Dtos/Channels/AutoTriggerTestInput.cs create mode 100644 src/Oms.Application/Dtos/Channels/AutoTriggerTestOutput.cs create mode 100644 src/Oms.Application/Dtos/Orders/OrderPriceCheckShippingOptionsOutput.cs create mode 100644 src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionParser.cs create mode 100644 src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs create mode 100644 src/Oms.Application/Services/Orders/Issues/AutoTriggerRuleResolver.cs create mode 100644 src/Oms.Application/Services/Orders/Issues/ChineseFormulaEvaluator.cs create mode 100644 src/Oms.Application/Services/Orders/Issues/OrderAutoTriggerService.cs create mode 100644 src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs create mode 100644 src/Oms.Application/Services/Orders/Issues/OrderRuleEvaluationContext.cs diff --git a/db.sql b/db.sql index f0eaa9b..c108792 100644 --- a/db.sql +++ b/db.sql @@ -730,6 +730,8 @@ CREATE TABLE `t_weight_rounding_info` ( CREATE TABLE `t_auto_trigger` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Status` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', + `RuleName` VARCHAR(128) NULL COMMENT '规则名称', + `Priority` INT NOT NULL DEFAULT 100 COMMENT '优先级,数值越小越先执行', `Operation` VARCHAR(32) NULL COMMENT '操作:拦截/警告/问题件', `TriggerTiming` VARCHAR(32) NULL COMMENT '触发时机:下单/入库/出库', `TriggerCondition` TEXT COMMENT '触发条件', diff --git a/sql/migrate_t_auto_trigger_rule_meta.sql b/sql/migrate_t_auto_trigger_rule_meta.sql new file mode 100644 index 0000000..21058e4 --- /dev/null +++ b/sql/migrate_t_auto_trigger_rule_meta.sql @@ -0,0 +1,18 @@ +-- t_auto_trigger 规则名与优先级(可重复执行) +-- 在 OMS 业务库执行:mysql -h -u -p < 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; diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerMetaOutput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerMetaOutput.cs new file mode 100644 index 0000000..e81ab8f --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerMetaOutput.cs @@ -0,0 +1,32 @@ +namespace Oms.Application.Dtos.Channels; + +public class AutoTriggerMetaOutput +{ + public List Categories { get; set; } = []; + + public List 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; } +} diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerOutput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerOutput.cs index 70b0130..a401f89 100644 --- a/src/Oms.Application/Dtos/Channels/AutoTriggerOutput.cs +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerOutput.cs @@ -15,6 +15,16 @@ public class AutoTriggerOutput /// public byte Status { get; set; } + /// + /// 规则名称。 + /// + public string? RuleName { get; set; } + + /// + /// 优先级。 + /// + public int Priority { get; set; } + /// /// 操作类型。 /// diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerSaveInput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerSaveInput.cs index d71ca50..8f65fac 100644 --- a/src/Oms.Application/Dtos/Channels/AutoTriggerSaveInput.cs +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerSaveInput.cs @@ -17,6 +17,16 @@ public class AutoTriggerSaveInput /// public EnableStatus Status { get; set; } + /// + /// 规则名称。 + /// + public string? RuleName { get; set; } + + /// + /// 优先级,数值越小越先执行。 + /// + public int Priority { get; set; } = 100; + /// /// 操作类型。 /// diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerTestInput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerTestInput.cs new file mode 100644 index 0000000..67f9d27 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerTestInput.cs @@ -0,0 +1,49 @@ +namespace Oms.Application.Dtos.Channels; + +public class AutoTriggerTestInput +{ + /// + /// 公式正文(与 TriggerCondition 二选一)。 + /// + public string? Expression { get; set; } + + /// + /// 完整触发条件(纯公式或 JSON 包装)。 + /// + public string? TriggerCondition { get; set; } + + /// + /// 使用已有订单作为评估上下文。 + /// + public int? OrderId { get; set; } + + /// + /// 使用样例数据作为预报前评估上下文。 + /// + 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; } +} diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerTestOutput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerTestOutput.cs new file mode 100644 index 0000000..f18f663 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerTestOutput.cs @@ -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; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckShippingOptionsOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckShippingOptionsOutput.cs new file mode 100644 index 0000000..2cbfd01 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckShippingOptionsOutput.cs @@ -0,0 +1,38 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 查价运输方式分组选项。 +/// +public class OrderPriceCheckShippingOptionsOutput +{ + public List Groups { get; set; } = []; +} + +public class OrderPriceCheckShippingGroupOutput +{ + /// + /// 分组编码(字典 sales_product_group.dictCode)。 + /// + public string GroupCode { get; set; } = string.Empty; + + /// + /// 分组名称(字典 sales_product_group.dictName)。 + /// + public string GroupName { get; set; } = string.Empty; + + public List 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; + + /// + /// 下拉展示文案。 + /// + public string Label { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Channels/AutoTriggerService.cs b/src/Oms.Application/Services/Channels/AutoTriggerService.cs index c7914aa..26d2ee3 100644 --- a/src/Oms.Application/Services/Channels/AutoTriggerService.cs +++ b/src/Oms.Application/Services/Channels/AutoTriggerService.cs @@ -3,6 +3,7 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Shared; +using Oms.Application.Services.Orders.Issues; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -16,12 +17,11 @@ namespace Oms.Application.Services.Channels; public class AutoTriggerService : ApplicationService { private IAutoTriggerRepository AutoTriggerRepository => LazyServiceProvider.GetRequiredService(); + private OrderAutoTriggerService OrderAutoTriggerService => LazyServiceProvider.GetRequiredService(); /// /// 查询自动触发规则列表。 /// - /// 入参:自动触发规则列表查询条件。 - /// 自动触发规则分页结果,包含列表数据和总记录数。 [HttpGet("/channel/auto-trigger/list")] public async Task> GetListAsync([FromQuery] AutoTriggerListInput input) { @@ -32,23 +32,37 @@ public class AutoTriggerService : ApplicationService predicate, pageIndex, pageSize, - new Dictionary { [nameof(AutoTrigger.UpdateTime)] = false, [nameof(AutoTrigger.Id)] = false }); + new Dictionary { [nameof(AutoTrigger.Priority)] = true, [nameof(AutoTrigger.Id)] = true }); var total = await AutoTriggerRepository.CountAsync(predicate); var items = entities.Select(MapOutput).ToList(); return new OmsPageResult { Items = items, Total = total }; } + /// + /// 获取公式字段元数据。 + /// + [HttpGet("/channel/auto-trigger/meta")] + public Task GetMetaAsync() => + Task.FromResult(AutoTriggerFormulaMeta.BuildMetaOutput()); + + /// + /// 测试公式是否命中。 + /// + [HttpPost("/channel/auto-trigger/test")] + public Task TestFormulaAsync(AutoTriggerTestInput input) => + OrderAutoTriggerService.TestFormulaAsync(input); + /// /// 新增自动触发规则。 /// - /// 入参:自动触发规则保存参数。 - /// 无返回体,仅完成新增自动触发规则。 [HttpPost("/channel/auto-trigger/create")] public async Task CreateAsync(AutoTriggerSaveInput input) { var entity = new AutoTrigger { Status = input.Status, + RuleName = NormalizeRuleName(input), + Priority = NormalizePriority(input), Operation = input.Operation, TriggerTiming = input.TriggerTiming, TriggerCondition = input.TriggerCondition, @@ -61,13 +75,13 @@ public class AutoTriggerService : ApplicationService /// /// 更新自动触发规则。 /// - /// 入参:自动触发规则保存参数。 - /// 无返回体,仅完成更新自动触发规则。 [HttpPost("/channel/auto-trigger/update")] public async Task UpdateAsync(AutoTriggerSaveInput input) { var entity = await GetEntityOrThrowAsync(input.Id); entity.Status = input.Status; + entity.RuleName = NormalizeRuleName(input); + entity.Priority = NormalizePriority(input); entity.Operation = input.Operation; entity.TriggerTiming = input.TriggerTiming; entity.TriggerCondition = input.TriggerCondition; @@ -79,8 +93,6 @@ public class AutoTriggerService : ApplicationService /// /// 删除自动触发规则。 /// - /// 入参:目标主键。 - /// 无返回体,仅完成删除自动触发规则。 [HttpPost("/channel/auto-trigger/delete")] public async Task DeleteAsync(IdInput input) { @@ -91,8 +103,6 @@ public class AutoTriggerService : ApplicationService /// /// 切换自动触发规则状态。 /// - /// 入参:目标主键和状态值。 - /// 无返回体,仅完成切换自动触发规则状态。 [HttpPost("/channel/auto-trigger/toggleStatus")] public async Task ToggleStatusAsync(ToggleStatusInput input) { @@ -126,12 +136,35 @@ public class AutoTriggerService : ApplicationService 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) { return new AutoTriggerOutput { Id = entity.Id, Status = (byte)entity.Status, + RuleName = AutoTriggerRuleResolver.GetRuleName(entity), + Priority = AutoTriggerRuleResolver.GetPriority(entity), Operation = entity.Operation, TriggerTiming = entity.TriggerTiming, TriggerCondition = entity.TriggerCondition, diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionMatcher.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionMatcher.cs index 8467375..7965011 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionMatcher.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionMatcher.cs @@ -6,7 +6,10 @@ namespace Oms.Application.Services.Orders.Issues; 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(); if (string.IsNullOrWhiteSpace(text)) @@ -14,18 +17,37 @@ public static class AutoTriggerConditionMatcher 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 (IssueTypeCodes.All.Contains(lowered)) + if (parsed.IsLegacyJsonMatcher && text.StartsWith('{') && text.EndsWith('}')) { - 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) @@ -80,8 +102,18 @@ public static class AutoTriggerConditionMatcher .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 { using var doc = JsonDocument.Parse(json); diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionParser.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionParser.cs new file mode 100644 index 0000000..e1336fe --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerConditionParser.cs @@ -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; + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs new file mode 100644 index 0000000..50ae08e --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs @@ -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); + +/// +/// 中文字段名 → 库表字段映射(与前端 formulaMeta.ts 保持一致)。 +/// +public static class AutoTriggerFormulaMeta +{ + public static IReadOnlyDictionary LabelToRef { get; } + + public static IReadOnlyDictionary LabelValueTypes { get; } + + /// 按长度降序排列的标签,供词法分析最长匹配。 + public static IReadOnlyList LabelsByLengthDesc { get; } + + static AutoTriggerFormulaMeta() + { + var fields = BuildFieldDefs(); + var labelToRef = new Dictionary(StringComparer.Ordinal); + var labelValueTypes = new Dictionary(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 + { + 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); + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuleResolver.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuleResolver.cs new file mode 100644 index 0000000..30cd022 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuleResolver.cs @@ -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; + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index c0f88f4..8d06daa 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -12,10 +12,13 @@ namespace Oms.Application.Services.Orders.Issues; public class AutoTriggerRuntimeService( IAutoTriggerRepository autoTriggerRepository, IOrderRepository orderRepository, + OrderAutoTriggerService orderAutoTriggerService, OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, ILogger logger) : IScopedDependency { + private const string InterceptOperation = "拦截"; + public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId) { if (order.Id <= 0) @@ -23,15 +26,25 @@ public class AutoTriggerRuntimeService( return; } + var context = await orderAutoTriggerService.BuildPostCommitContextAsync(order); 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; } - if (!AutoTriggerConditionMatcher.Matches(order, trigger.TriggerCondition)) + if (!AutoTriggerConditionMatcher.Matches(context, trigger.TriggerCondition)) { continue; } @@ -64,7 +77,7 @@ public class AutoTriggerRuntimeService( int operatorUserId) { var operation = trigger.Operation?.Trim(); - if (string.Equals(operation, "拦截", StringComparison.Ordinal)) + if (string.Equals(operation, InterceptOperation, StringComparison.Ordinal)) { if (order.IsIntercept == 0) { diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerTimingCodes.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerTimingCodes.cs index 36bc71c..1fd495f 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerTimingCodes.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerTimingCodes.cs @@ -23,7 +23,7 @@ public static class AutoTriggerTimingCodes var text = triggerTiming.Trim(); 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.Outbound => ContainsAny(text, "出库", "outbound", "ship", "发货"), _ => false diff --git a/src/Oms.Application/Services/Orders/Issues/ChineseFormulaEvaluator.cs b/src/Oms.Application/Services/Orders/Issues/ChineseFormulaEvaluator.cs new file mode 100644 index 0000000..0017e30 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/ChineseFormulaEvaluator.cs @@ -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(); + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/OrderAutoTriggerService.cs b/src/Oms.Application/Services/Orders/Issues/OrderAutoTriggerService.cs new file mode 100644 index 0000000..c530b0a --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/OrderAutoTriggerService.cs @@ -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 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 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> 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 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); + } +} diff --git a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs new file mode 100644 index 0000000..c62bc0b --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs @@ -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); +} diff --git a/src/Oms.Application/Services/Orders/Issues/OrderRuleEvaluationContext.cs b/src/Oms.Application/Services/Orders/Issues/OrderRuleEvaluationContext.cs new file mode 100644 index 0000000..bcebf74 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Issues/OrderRuleEvaluationContext.cs @@ -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 Declarations { get; init; } = []; + + public IReadOnlyList Weights { get; init; } = []; + + public IReadOnlyList 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? declarations, + IReadOnlyList? weights, + IReadOnlyList? 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); +} diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index d199e55..03f09d4 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -37,6 +37,9 @@ public partial class OrderService private AutoTriggerRuntimeService AutoTriggerRuntimeService => LazyServiceProvider.GetRequiredService(); + private OrderAutoTriggerService OrderAutoTriggerService => + LazyServiceProvider.GetRequiredService(); + private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, int? operatorUserId = null) @@ -78,6 +81,12 @@ public partial class OrderService throw new BusinessException("收件地址命中黑名单区域"); } + await OrderAutoTriggerService.EvaluatePreCommitAsync( + input, + sessionCustomer, + country.CountryCode, + country.ChineseName); + var serviceNo = await ResolveServiceNoAsync(); var order = BuildDraftOrderEntity(input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo); order = await OrderRepository.InsertAsync(order); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index d2707a6..070733f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -9,8 +9,94 @@ namespace Oms.Application.Services.Orders; 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); + public async Task 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(); + var consumedCodes = new HashSet(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 BuildPriceCheckResultAsync(OrderPriceCheckInput input) { ValidatePriceCheckInput(input); diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 8a08e91..d868605 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -254,6 +254,13 @@ public partial class OrderService : ApplicationService }; } + /// + /// 查价运输方式选项(按销售产品分组)。 + /// + [HttpGet("/order/price-check/shipping-options")] + public Task GetPriceCheckShippingOptionsAsync() => + BuildPriceCheckShippingOptionsAsync(); + /// /// 执行订单运费试算。 /// diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/AutoTrigger.cs b/src/Oms.Domain/ChannelsAggregate/Entities/AutoTrigger.cs index ac6c253..5efe4c1 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/AutoTrigger.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/AutoTrigger.cs @@ -9,6 +9,10 @@ public class AutoTrigger : OmsAuditedAggregateRoot { public EnableStatus Status { get; set; } + public string? RuleName { get; set; } + + public int Priority { get; set; } = 100; + public string? Operation { get; set; } public string? TriggerTiming { get; set; }