diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
new file mode 100644
index 0000000..9a63e80
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Http;
+
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 自动触发规则 Excel 导入入参。
+///
+public class AutoTriggerImportInput
+{
+ ///
+ /// 导入模式:incremental=增量;full=全量(先清空再导入)。
+ ///
+ public string ImportMode { get; set; } = ImportModes.Incremental;
+
+ ///
+ /// Excel 文件。
+ ///
+ public IFormFile File { get; set; } = null!;
+
+ public static class ImportModes
+ {
+ public const string Incremental = "incremental";
+ public const string Full = "full";
+ }
+}
diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
new file mode 100644
index 0000000..e73fe1e
--- /dev/null
+++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
@@ -0,0 +1,25 @@
+namespace Oms.Application.Dtos.Channels;
+
+///
+/// 自动触发规则 Excel 导入结果。
+///
+public class AutoTriggerImportOutput
+{
+ public int SuccessCount { get; set; }
+
+ public int SkipCount { get; set; }
+
+ public int FailCount { get; set; }
+
+ public List Failures { get; set; } = [];
+}
+
+///
+/// 导入失败行。
+///
+public class AutoTriggerImportFailRow
+{
+ public int RowNumber { get; set; }
+
+ public string Reason { get; set; } = string.Empty;
+}
diff --git a/src/Oms.Application/Services/Channels/AutoTriggerService.cs b/src/Oms.Application/Services/Channels/AutoTriggerService.cs
index 97477f5..a5cad73 100644
--- a/src/Oms.Application/Services/Channels/AutoTriggerService.cs
+++ b/src/Oms.Application/Services/Channels/AutoTriggerService.cs
@@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService
return new OmsPageResult { Items = items, Total = total };
}
+ ///
+ /// 下载自动触发规则导入模板。
+ ///
+ [HttpGet("/channel/auto-trigger/import-template")]
+ public async Task DownloadImportTemplateAsync()
+ {
+ var bytes = await AutoTriggerImportHelper.BuildTemplateBytesAsync();
+ return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ {
+ FileDownloadName = AutoTriggerImportHelper.TemplateFileName
+ };
+ }
+
+ ///
+ /// 导入自动触发规则(Excel)。
+ ///
+ [HttpPost("/channel/auto-trigger/import")]
+ public async Task ImportAsync([FromForm] AutoTriggerImportInput input)
+ {
+ if (input.File == null || input.File.Length == 0)
+ {
+ throw new BusinessException("请上传 Excel 文件");
+ }
+
+ var importMode = NormalizeImportMode(input.ImportMode);
+ await using var stream = input.File.OpenReadStream();
+ var parsedRows = await AutoTriggerImportHelper.ParseImportRowsAsync(stream);
+ if (parsedRows.Count == 0)
+ {
+ throw new BusinessException("Excel 中无有效数据行");
+ }
+
+ if (importMode == AutoTriggerImportInput.ImportModes.Full)
+ {
+ var existing = await AutoTriggerRepository.GetListAsync(_ => true);
+ foreach (var entity in existing)
+ {
+ await AutoTriggerRepository.DeleteAsync(entity);
+ }
+ }
+
+ var output = new AutoTriggerImportOutput();
+ var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var (rowNumber, entity) in parsedRows)
+ {
+ try
+ {
+ AutoTriggerImportHelper.ValidateEntity(entity);
+ entity.TriggerTiming = AutoTriggerTimingCodes.ToCanonicalLabel(entity.TriggerTiming);
+ var key = BuildDuplicateKey(entity);
+ if (!seenKeys.Add(key))
+ {
+ output.SkipCount++;
+ output.Failures.Add(new AutoTriggerImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = "文件内规则重复,已跳过"
+ });
+ continue;
+ }
+
+ if (importMode == AutoTriggerImportInput.ImportModes.Incremental)
+ {
+ var duplicated = await AutoTriggerRepository.FirstOrDefaultAsync(x =>
+ x.RuleName == entity.RuleName
+ && x.Operation == entity.Operation
+ && x.TriggerTiming == entity.TriggerTiming
+ && x.TriggerCondition == entity.TriggerCondition);
+ if (duplicated != null)
+ {
+ output.SkipCount++;
+ output.Failures.Add(new AutoTriggerImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = "与已有规则重复,已跳过"
+ });
+ continue;
+ }
+ }
+
+ await AutoTriggerRepository.InsertAsync(entity);
+ output.SuccessCount++;
+ }
+ catch (Exception ex)
+ {
+ output.FailCount++;
+ output.Failures.Add(new AutoTriggerImportFailRow
+ {
+ RowNumber = rowNumber,
+ Reason = ex.Message
+ });
+ }
+ }
+
+ return output;
+ }
+
+ private static string NormalizeImportMode(string? mode)
+ {
+ if (string.Equals(mode, AutoTriggerImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase))
+ {
+ return AutoTriggerImportInput.ImportModes.Full;
+ }
+
+ return AutoTriggerImportInput.ImportModes.Incremental;
+ }
+
+ private static string BuildDuplicateKey(AutoTrigger entity) =>
+ $"{entity.RuleName}|{entity.Operation}|{entity.TriggerTiming}|{entity.TriggerCondition}";
+
///
/// 获取公式字段元数据。
///
diff --git a/src/Oms.Application/Shared/AutoTriggerImportHelper.cs b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs
new file mode 100644
index 0000000..7c7c66b
--- /dev/null
+++ b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs
@@ -0,0 +1,290 @@
+using MiniExcelLibs;
+using Oms.Application.Services.Orders.Issues;
+using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.Shared.Enums;
+
+namespace Oms.Application.Shared;
+
+///
+/// 自动触发规则 Excel 模板与导入解析。
+///
+public static class AutoTriggerImportHelper
+{
+ public const string TemplateSheetName = "导入模板";
+ public const string EnumSheetName = "枚举值";
+
+ private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["规则名称"] = nameof(AutoTrigger.RuleName),
+ ["RuleName"] = nameof(AutoTrigger.RuleName),
+ ["ruleName"] = nameof(AutoTrigger.RuleName),
+ ["优先级"] = nameof(AutoTrigger.Priority),
+ ["Priority"] = nameof(AutoTrigger.Priority),
+ ["priority"] = nameof(AutoTrigger.Priority),
+ ["状态"] = nameof(AutoTrigger.Status),
+ ["Status"] = nameof(AutoTrigger.Status),
+ ["status"] = nameof(AutoTrigger.Status),
+ ["操作"] = nameof(AutoTrigger.Operation),
+ ["Operation"] = nameof(AutoTrigger.Operation),
+ ["operation"] = nameof(AutoTrigger.Operation),
+ ["触发时机"] = nameof(AutoTrigger.TriggerTiming),
+ ["TriggerTiming"] = nameof(AutoTrigger.TriggerTiming),
+ ["triggerTiming"] = nameof(AutoTrigger.TriggerTiming),
+ ["触发条件"] = nameof(AutoTrigger.TriggerCondition),
+ ["TriggerCondition"] = nameof(AutoTrigger.TriggerCondition),
+ ["triggerCondition"] = nameof(AutoTrigger.TriggerCondition),
+ ["提示语"] = nameof(AutoTrigger.PromptMessage),
+ ["提示信息"] = nameof(AutoTrigger.PromptMessage),
+ ["PromptMessage"] = nameof(AutoTrigger.PromptMessage),
+ ["promptMessage"] = nameof(AutoTrigger.PromptMessage),
+ ["通知人"] = nameof(AutoTrigger.Notifier),
+ ["通知对象"] = nameof(AutoTrigger.Notifier),
+ ["Notifier"] = nameof(AutoTrigger.Notifier),
+ ["notifier"] = nameof(AutoTrigger.Notifier),
+ };
+
+ public static async Task BuildTemplateBytesAsync()
+ {
+ var templateRows = new List>
+ {
+ new()
+ {
+ ["规则名称"] = "英国超尺寸拦截示例",
+ ["优先级"] = 100,
+ ["状态"] = "启用",
+ ["操作"] = "拦截",
+ ["触发时机"] = "下单",
+ ["触发条件"] = "{\n \"expression\": \"收件国家 == \\\"GB\\\"\"\n}",
+ ["提示语"] = "超尺寸,禁止下单",
+ ["通知人"] = ""
+ }
+ };
+
+ var enumRows = new List>
+ {
+ new() { ["枚举类型"] = "状态", ["编码"] = "2", ["名称"] = "启用" },
+ new() { ["枚举类型"] = "状态", ["编码"] = "1", ["名称"] = "禁用" },
+ new() { ["枚举类型"] = "操作", ["编码"] = "intercept", ["名称"] = "拦截" },
+ new() { ["枚举类型"] = "操作", ["编码"] = "warn", ["名称"] = "警告" },
+ new() { ["枚举类型"] = "操作", ["编码"] = "problem", ["名称"] = "问题件" },
+ new() { ["枚举类型"] = "触发时机", ["编码"] = "order_create", ["名称"] = "下单" },
+ new() { ["枚举类型"] = "触发时机", ["编码"] = "inbound", ["名称"] = "入库" },
+ new() { ["枚举类型"] = "触发时机", ["编码"] = "outbound", ["名称"] = "出库" },
+ };
+
+ var sheets = new Dictionary
+ {
+ [TemplateSheetName] = templateRows,
+ [EnumSheetName] = enumRows
+ };
+
+ await using var stream = new MemoryStream();
+ await MiniExcel.SaveAsAsync(stream, sheets);
+ return stream.ToArray();
+ }
+
+ public static string TemplateFileName => "自动触发规则导入模板.xlsx";
+
+ internal static string? ResolveImportSheetName(IEnumerable sheetNames)
+ {
+ var names = sheetNames.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+ if (names.Count == 0)
+ {
+ return null;
+ }
+
+ return names.FirstOrDefault(x =>
+ string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase))
+ ?? names.FirstOrDefault(x =>
+ !string.Equals(x, EnumSheetName, StringComparison.OrdinalIgnoreCase))
+ ?? names[0];
+ }
+
+ public static async Task> ParseImportRowsAsync(Stream fileStream)
+ {
+ if (fileStream.CanSeek)
+ {
+ fileStream.Position = 0;
+ }
+
+ var sheetNames = MiniExcel.GetSheetNames(fileStream);
+ var sheetName = ResolveImportSheetName(sheetNames);
+
+ if (fileStream.CanSeek)
+ {
+ fileStream.Position = 0;
+ }
+
+ var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName);
+ var result = new List<(int RowNumber, AutoTrigger Entity)>();
+ var rowNumber = 1;
+
+ foreach (var row in rows)
+ {
+ rowNumber++;
+ if (row is not IDictionary rowDict)
+ {
+ continue;
+ }
+
+ if (IsEmptyRow(rowDict))
+ {
+ continue;
+ }
+
+ result.Add((rowNumber, ParseRow(rowDict)));
+ }
+
+ return result;
+ }
+
+ public static void ValidateEntity(AutoTrigger entity)
+ {
+ if (string.IsNullOrWhiteSpace(entity.RuleName))
+ {
+ throw new InvalidOperationException("规则名称不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.TriggerCondition))
+ {
+ throw new InvalidOperationException("触发条件不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.Operation))
+ {
+ throw new InvalidOperationException("操作不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.TriggerTiming))
+ {
+ throw new InvalidOperationException("触发时机不能为空");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.PromptMessage))
+ {
+ entity.PromptMessage = entity.RuleName;
+ }
+
+ if (entity.Priority <= 0)
+ {
+ entity.Priority = 100;
+ }
+ }
+
+ private static AutoTrigger ParseRow(IDictionary rowDict)
+ {
+ var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (header, value) in rowDict)
+ {
+ if (string.IsNullOrWhiteSpace(header))
+ {
+ continue;
+ }
+
+ if (!HeaderAliases.TryGetValue(header.Trim(), out var field))
+ {
+ continue;
+ }
+
+ mapped[field] = NormalizeCell(value);
+ }
+
+ var operation = NormalizeOperation(mapped.GetValueOrDefault(nameof(AutoTrigger.Operation)));
+ var timing = AutoTriggerTimingCodes.ToCanonicalLabel(
+ mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerTiming)));
+ var status = NormalizeStatus(mapped.GetValueOrDefault(nameof(AutoTrigger.Status)));
+ var priorityText = mapped.GetValueOrDefault(nameof(AutoTrigger.Priority));
+ var priority = int.TryParse(priorityText, out var p) && p > 0 ? p : 100;
+
+ return new AutoTrigger
+ {
+ RuleName = mapped.GetValueOrDefault(nameof(AutoTrigger.RuleName))?.Trim(),
+ Priority = priority,
+ Status = status,
+ Operation = operation,
+ TriggerTiming = timing,
+ TriggerCondition = mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerCondition)),
+ PromptMessage = mapped.GetValueOrDefault(nameof(AutoTrigger.PromptMessage))?.Trim(),
+ Notifier = mapped.GetValueOrDefault(nameof(AutoTrigger.Notifier))?.Trim()
+ };
+ }
+
+ private static string NormalizeOperation(string? raw)
+ {
+ var text = (raw ?? string.Empty).Trim();
+ if (string.IsNullOrEmpty(text))
+ {
+ return string.Empty;
+ }
+
+ if (ContainsAny(text, "拦截", "intercept", "block"))
+ {
+ return "拦截";
+ }
+
+ if (ContainsAny(text, "警告", "warn", "warning"))
+ {
+ return "警告";
+ }
+
+ if (ContainsAny(text, "问题件", "problem", "issue"))
+ {
+ return "问题件";
+ }
+
+ return text;
+ }
+
+ private static EnableStatus NormalizeStatus(string? raw)
+ {
+ var text = (raw ?? string.Empty).Trim();
+ if (string.IsNullOrEmpty(text))
+ {
+ return EnableStatus.Enabled;
+ }
+
+ if (text is "1" or "禁用" or "停用" or "disabled" or "Disabled" or "false" or "False" or "0")
+ {
+ return EnableStatus.Disabled;
+ }
+
+ return EnableStatus.Enabled;
+ }
+
+ private static bool ContainsAny(string text, params string[] keywords)
+ {
+ foreach (var keyword in keywords)
+ {
+ if (text.Contains(keyword, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string? NormalizeCell(object? value)
+ {
+ if (value == null || value is DBNull)
+ {
+ return null;
+ }
+
+ var text = Convert.ToString(value)?.Trim();
+ return string.IsNullOrWhiteSpace(text) ? null : text;
+ }
+
+ private static bool IsEmptyRow(IDictionary rowDict)
+ {
+ foreach (var value in rowDict.Values)
+ {
+ if (!string.IsNullOrWhiteSpace(NormalizeCell(value)))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}