From d5d6305f9cbb622059d0f4a58025ddcd5616ea94 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 12 Oct 2026 15:31:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E8=A7=84=E5=88=99=E6=94=AF=E6=8C=81=20Excel=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=E6=A8=A1=E6=9D=BF=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Channels/AutoTriggerImportInput.cs | 25 ++ .../Dtos/Channels/AutoTriggerImportOutput.cs | 25 ++ .../Services/Channels/AutoTriggerService.cs | 112 +++++++ .../Shared/AutoTriggerImportHelper.cs | 290 ++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs create mode 100644 src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs create mode 100644 src/Oms.Application/Shared/AutoTriggerImportHelper.cs 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; + } +}