feat: 自动触发规则支持 Excel 导入与模板下载
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m2s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
32156dcfe0
commit
d5d6305f9c
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
Normal file
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
Normal file
25
src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs
Normal 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;
|
||||
}
|
||||
@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService
|
||||
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>
|
||||
|
||||
290
src/Oms.Application/Shared/AutoTriggerImportHelper.cs
Normal file
290
src/Oms.Application/Shared/AutoTriggerImportHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user