feat: 自动触发规则支持 Excel 导入与模板下载
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-12 15:31:19 +08:00
parent 32156dcfe0
commit d5d6305f9c
4 changed files with 452 additions and 0 deletions

View File

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 自动触发规则 Excel 导入入参。
/// </summary>
public class AutoTriggerImportInput
{
/// <summary>
/// 导入模式:incremental=增量;full=全量(先清空再导入)。
/// </summary>
public string ImportMode { get; set; } = ImportModes.Incremental;
/// <summary>
/// Excel 文件。
/// </summary>
public IFormFile File { get; set; } = null!;
public static class ImportModes
{
public const string Incremental = "incremental";
public const string Full = "full";
}
}

View File

@ -0,0 +1,25 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 自动触发规则 Excel 导入结果。
/// </summary>
public class AutoTriggerImportOutput
{
public int SuccessCount { get; set; }
public int SkipCount { get; set; }
public int FailCount { get; set; }
public List<AutoTriggerImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class AutoTriggerImportFailRow
{
public int RowNumber { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService
return new OmsPageResult<AutoTriggerOutput> { Items = items, Total = total }; return new OmsPageResult<AutoTriggerOutput> { Items = items, Total = total };
} }
/// <summary>
/// 下载自动触发规则导入模板。
/// </summary>
[HttpGet("/channel/auto-trigger/import-template")]
public async Task<FileContentResult> DownloadImportTemplateAsync()
{
var bytes = await AutoTriggerImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = AutoTriggerImportHelper.TemplateFileName
};
}
/// <summary>
/// 导入自动触发规则(Excel)。
/// </summary>
[HttpPost("/channel/auto-trigger/import")]
public async Task<AutoTriggerImportOutput> 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<string>(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}";
/// <summary> /// <summary>
/// 获取公式字段元数据。 /// 获取公式字段元数据。
/// </summary> /// </summary>

View File

@ -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;
/// <summary>
/// 自动触发规则 Excel 模板与导入解析。
/// </summary>
public static class AutoTriggerImportHelper
{
public const string TemplateSheetName = "导入模板";
public const string EnumSheetName = "枚举值";
private static readonly Dictionary<string, string> 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<byte[]> BuildTemplateBytesAsync()
{
var templateRows = new List<Dictionary<string, object?>>
{
new()
{
["规则名称"] = "英国超尺寸拦截示例",
["优先级"] = 100,
["状态"] = "启用",
["操作"] = "拦截",
["触发时机"] = "下单",
["触发条件"] = "{\n \"expression\": \"收件国家 == \\\"GB\\\"\"\n}",
["提示语"] = "超尺寸,禁止下单",
["通知人"] = ""
}
};
var enumRows = new List<Dictionary<string, object?>>
{
new() { ["枚举类型"] = "状态", ["编码"] = "2", ["名称"] = "启用" },
new() { ["枚举类型"] = "状态", ["编码"] = "1", ["名称"] = "禁用" },
new() { ["枚举类型"] = "操作", ["编码"] = "intercept", ["名称"] = "拦截" },
new() { ["枚举类型"] = "操作", ["编码"] = "warn", ["名称"] = "警告" },
new() { ["枚举类型"] = "操作", ["编码"] = "problem", ["名称"] = "问题件" },
new() { ["枚举类型"] = "触发时机", ["编码"] = "order_create", ["名称"] = "下单" },
new() { ["枚举类型"] = "触发时机", ["编码"] = "inbound", ["名称"] = "入库" },
new() { ["枚举类型"] = "触发时机", ["编码"] = "outbound", ["名称"] = "出库" },
};
var sheets = new Dictionary<string, object>
{
[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<string> 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<List<(int RowNumber, AutoTrigger Entity)>> 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<string, object> 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<string, object> rowDict)
{
var mapped = new Dictionary<string, string?>(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<string, object> rowDict)
{
foreach (var value in rowDict.Values)
{
if (!string.IsNullOrWhiteSpace(NormalizeCell(value)))
{
return false;
}
}
return true;
}
}