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 };
|
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>
|
||||||
|
|||||||
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