diff --git a/src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs b/src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs
index 0c2f531..f6c678a 100644
--- a/src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs
@@ -86,6 +86,8 @@ public class OrderTemplateSaveMappingInput
{
public int TemplateId { get; set; }
+ public string? StandardTemplateKey { get; set; }
+
public int HeaderRowIndex { get; set; } = 1;
public int DataStartRowIndex { get; set; } = 2;
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index cb3937c..b2b5427 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/Oms.Application/Services/Orders/OrderService.Template.cs b/src/Oms.Application/Services/Orders/OrderService.Template.cs
index 4f6b4e0..ecc3126 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Template.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Template.cs
@@ -149,7 +149,10 @@ public partial class OrderService
throw new BusinessException("模板表头为空,请重新上传样例文件");
}
- var standardKey = template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel;
+ var standardKey = !string.IsNullOrWhiteSpace(input.StandardTemplateKey)
+ ? input.StandardTemplateKey.Trim()
+ : template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel;
+ template.StandardTemplateKey = standardKey;
var headerSet = sourceHeaders.ToHashSet(StringComparer.OrdinalIgnoreCase);
var usedColumns = new HashSet(StringComparer.OrdinalIgnoreCase);
var mappings = new List();
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs
index 4e43560..b7d3b9e 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs
@@ -11,7 +11,7 @@ public class OrderTemplateColumnMappingDocument
public int HeaderRowIndex { get; set; } = 1;
- public int DataStartRowIndex { get; set; } = 4;
+ public int DataStartRowIndex { get; set; } = 2;
public List Mappings { get; set; } = [];
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs
index 64b3dea..4f2cdfc 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs
@@ -8,8 +8,8 @@ namespace Oms.Application.Services.Orders.Templates;
public static class OrderTemplateExcelGenerator
{
public const string DataSheetName = "下单数据";
- private const string GuideSheetName = "字段说明";
- private const string DictSheetName = "下拉选项";
+ public const string GuideSheetName = "字段说明";
+ public const string DictSheetName = "下拉选项";
public static async Task BuildStandardTemplateAsync(
string templateKey,
@@ -17,21 +17,13 @@ public static class OrderTemplateExcelGenerator
IReadOnlyList? dictOptions = null)
{
var fields = OrderTemplateFieldRegistry.GetFields(templateKey);
- var overrides = exampleOverrides ?? new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _ = exampleOverrides;
- var requiredRow = new Dictionary(StringComparer.Ordinal);
- var exampleRow = new Dictionary(StringComparer.Ordinal);
+ // 仅生成中文表头行(MiniExcel 第一行表头 + 第二行留空供用户填写)
+ var emptyDataRow = new Dictionary(StringComparer.Ordinal);
foreach (var field in fields)
{
- requiredRow[field.Label] = field.Required ? "*" : string.Empty;
- if (overrides.TryGetValue(field.FieldKey, out var overrideExample) && !string.IsNullOrWhiteSpace(overrideExample))
- {
- exampleRow[field.Label] = overrideExample;
- }
- else
- {
- exampleRow[field.Label] = string.IsNullOrWhiteSpace(field.Example) ? string.Empty : field.Example;
- }
+ emptyDataRow[field.Label] = string.Empty;
}
var guideRows = fields.Select(f => new Dictionary
@@ -41,6 +33,7 @@ public static class OrderTemplateExcelGenerator
["是否必填"] = f.Required ? "是" : "否",
["数据类型"] = f.DataType,
["字典类型"] = f.DictType ?? string.Empty,
+ ["可选值"] = BuildGuideOptionText(f, dictOptions ?? []),
["分组"] = f.Group,
["示例"] = f.Example
}).ToList();
@@ -66,13 +59,36 @@ public static class OrderTemplateExcelGenerator
var sheets = new Dictionary
{
- [DataSheetName] = new List> { requiredRow, exampleRow },
+ [DataSheetName] = new List> { emptyDataRow },
[GuideSheetName] = guideRows,
[DictSheetName] = dictRows
};
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, sheets);
- return stream.ToArray();
+ return OrderTemplateExcelOpenXmlEnhancer.Enhance(stream.ToArray(), fields, dictOptions ?? []);
+ }
+
+ private static string BuildGuideOptionText(
+ OrderTemplateFieldDefinition field,
+ IReadOnlyList dictOptions)
+ {
+ if (string.Equals(field.DataType, "boolean", StringComparison.OrdinalIgnoreCase))
+ {
+ return "true / false";
+ }
+
+ if (string.IsNullOrWhiteSpace(field.DictType))
+ {
+ return string.Empty;
+ }
+
+ var options = dictOptions
+ .Where(x => string.Equals(x.DictType, field.DictType, StringComparison.OrdinalIgnoreCase))
+ .Select(x => string.IsNullOrWhiteSpace(x.Label) ? x.Code : $"{x.Label} ({x.Code})")
+ .Take(20)
+ .ToList();
+
+ return options.Count == 0 ? string.Empty : string.Join(";", options);
}
}
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelOpenXmlEnhancer.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelOpenXmlEnhancer.cs
new file mode 100644
index 0000000..71ce87e
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelOpenXmlEnhancer.cs
@@ -0,0 +1,344 @@
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Spreadsheet;
+
+namespace Oms.Application.Services.Orders.Templates;
+
+///
+/// 为标准模板 Excel 增加表头必填标黄、枚举下拉等 OpenXML 增强。
+///
+internal static class OrderTemplateExcelOpenXmlEnhancer
+{
+ private const uint DataValidationStartRow = 2;
+ private const uint DataValidationEndRow = 5000;
+ private const string YellowHeaderRgb = "FFFFFF00";
+
+ public static byte[] Enhance(
+ byte[] workbookBytes,
+ IReadOnlyList fields,
+ IReadOnlyList dictOptions)
+ {
+ using var stream = new MemoryStream();
+ stream.Write(workbookBytes, 0, workbookBytes.Length);
+ stream.Position = 0;
+
+ using (var document = SpreadsheetDocument.Open(stream, true))
+ {
+ var workbookPart = document.WorkbookPart
+ ?? throw new InvalidOperationException("工作簿无效");
+
+ var requiredHeaderStyleIndex = EnsureRequiredHeaderStyle(workbookPart);
+ var dataSheetPart = GetWorksheetPart(workbookPart, OrderTemplateExcelGenerator.DataSheetName)
+ ?? throw new InvalidOperationException("未找到下单数据工作表");
+
+ ApplyRequiredHeaderHighlight(dataSheetPart.Worksheet, fields, requiredHeaderStyleIndex);
+ ApplyColumnValidations(workbookPart, dataSheetPart, fields, dictOptions);
+
+ dataSheetPart.Worksheet.Save();
+ workbookPart.Workbook.Save();
+ }
+
+ return stream.ToArray();
+ }
+
+ private static void ApplyRequiredHeaderHighlight(
+ Worksheet worksheet,
+ IReadOnlyList fields,
+ uint styleIndex)
+ {
+ var sheetData = worksheet.GetFirstChild()
+ ?? throw new InvalidOperationException("工作表数据为空");
+
+ var headerRow = sheetData.Elements().FirstOrDefault(x => x.RowIndex?.Value == 1)
+ ?? throw new InvalidOperationException("未找到表头行");
+
+ for (var columnIndex = 0; columnIndex < fields.Count; columnIndex++)
+ {
+ if (!fields[columnIndex].Required)
+ {
+ continue;
+ }
+
+ var cellReference = $"{ToColumnName(columnIndex)}1";
+ var cell = headerRow.Elements().FirstOrDefault(x => x.CellReference?.Value == cellReference)
+ ?? CreateCell(headerRow, cellReference, fields[columnIndex].Label);
+ cell.StyleIndex = styleIndex;
+ }
+ }
+
+ private static void ApplyColumnValidations(
+ WorkbookPart workbookPart,
+ WorksheetPart dataSheetPart,
+ IReadOnlyList fields,
+ IReadOnlyList dictOptions)
+ {
+ var dictRangeByType = BuildDictTypeRanges(workbookPart, dictOptions);
+ var validations = dataSheetPart.Worksheet.GetFirstChild();
+ if (validations == null)
+ {
+ validations = new DataValidations();
+ dataSheetPart.Worksheet.Append(validations);
+ }
+
+ for (var columnIndex = 0; columnIndex < fields.Count; columnIndex++)
+ {
+ var field = fields[columnIndex];
+ var formula = ResolveValidationFormula(field, dictRangeByType);
+ if (string.IsNullOrWhiteSpace(formula))
+ {
+ continue;
+ }
+
+ var columnName = ToColumnName(columnIndex);
+ var validation = new DataValidation
+ {
+ Type = DataValidationValues.List,
+ AllowBlank = !field.Required,
+ ShowInputMessage = true,
+ ShowErrorMessage = true,
+ SequenceOfReferences = new ListValue
+ {
+ InnerText = $"{columnName}{DataValidationStartRow}:{columnName}{DataValidationEndRow}"
+ }
+ };
+ validation.Append(new Formula1(formula));
+ validations.Append(validation);
+ }
+
+ validations.Count = (uint)validations.ChildElements.Count;
+ }
+
+ private static string? ResolveValidationFormula(
+ OrderTemplateFieldDefinition field,
+ IReadOnlyDictionary dictRangeByType)
+ {
+ if (string.Equals(field.DataType, "boolean", StringComparison.OrdinalIgnoreCase))
+ {
+ return "\"true,false\"";
+ }
+
+ if (string.IsNullOrWhiteSpace(field.DictType))
+ {
+ return null;
+ }
+
+ if (dictRangeByType.TryGetValue(field.DictType, out var rangeFormula))
+ {
+ return rangeFormula;
+ }
+
+ return null;
+ }
+
+ private static Dictionary BuildDictTypeRanges(
+ WorkbookPart workbookPart,
+ IReadOnlyList dictOptions)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (dictOptions.Count == 0)
+ {
+ return result;
+ }
+
+ var dictSheetPart = GetWorksheetPart(workbookPart, OrderTemplateExcelGenerator.DictSheetName);
+ if (dictSheetPart?.Worksheet == null)
+ {
+ return result;
+ }
+
+ var grouped = dictOptions
+ .Where(x => !string.IsNullOrWhiteSpace(x.DictType) && !string.IsNullOrWhiteSpace(x.Code))
+ .GroupBy(x => x.DictType, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var sheetData = dictSheetPart.Worksheet.GetFirstChild();
+ sheetData?.RemoveAllChildren();
+ sheetData ??= new SheetData();
+ if (dictSheetPart.Worksheet.GetFirstChild() == null)
+ {
+ dictSheetPart.Worksheet.Append(sheetData);
+ }
+
+ var headerRow = new Row { RowIndex = 1 };
+ sheetData.InsertAt(headerRow, 0);
+
+ var columnIndex = 0;
+ foreach (var group in grouped)
+ {
+ var columnName = ToColumnName(columnIndex);
+ SetCellText(headerRow, $"{columnName}1", group.Key);
+
+ var rowIndex = 2U;
+ foreach (var option in group.OrderBy(x => x.Code, StringComparer.OrdinalIgnoreCase))
+ {
+ var row = sheetData.Elements().FirstOrDefault(x => x.RowIndex?.Value == rowIndex)
+ ?? CreateRow(sheetData, rowIndex);
+ SetCellText(row, $"{columnName}{rowIndex}", option.Code);
+ rowIndex++;
+ }
+
+ if (rowIndex == 2)
+ {
+ columnIndex++;
+ continue;
+ }
+
+ var lastRow = rowIndex - 1;
+ var escapedSheetName = OrderTemplateExcelGenerator.DictSheetName.Replace("'", "''");
+ result[group.Key] = $"'{escapedSheetName}'!${columnName}$2:${columnName}${lastRow}";
+ columnIndex++;
+ }
+
+ dictSheetPart.Worksheet.Save();
+ return result;
+ }
+
+ private static uint EnsureRequiredHeaderStyle(WorkbookPart workbookPart)
+ {
+ var stylesPart = workbookPart.WorkbookStylesPart ?? workbookPart.AddNewPart();
+ var stylesheet = stylesPart.Stylesheet;
+ if (stylesheet == null)
+ {
+ stylesheet = new Stylesheet
+ {
+ Fonts = new Fonts(new Font()),
+ Fills = new Fills(
+ new Fill { PatternFill = new PatternFill { PatternType = PatternValues.None } },
+ new Fill { PatternFill = new PatternFill { PatternType = PatternValues.Gray125 } }),
+ Borders = new Borders(new Border()),
+ CellStyleFormats = new CellStyleFormats(new CellFormat()),
+ CellFormats = new CellFormats(new CellFormat())
+ };
+ stylesheet.Fonts.Count = 1;
+ stylesheet.Fills.Count = 2;
+ stylesheet.Borders.Count = 1;
+ stylesheet.CellStyleFormats.Count = 1;
+ stylesheet.CellFormats.Count = 1;
+ stylesPart.Stylesheet = stylesheet;
+ }
+
+ var fills = stylesheet.Fills ?? new Fills { Count = 0 };
+ stylesheet.Fills = fills;
+
+ var fonts = stylesheet.Fonts ?? new Fonts { Count = 0 };
+ stylesheet.Fonts = fonts;
+
+ var borders = stylesheet.Borders ?? new Borders { Count = 0 };
+ stylesheet.Borders = borders;
+
+ var cellFormats = stylesheet.CellFormats ?? new CellFormats { Count = 0 };
+ stylesheet.CellFormats = cellFormats;
+
+ if (fills.Count == 0)
+ {
+ fills.Append(new Fill { PatternFill = new PatternFill { PatternType = PatternValues.None } });
+ fills.Append(new Fill { PatternFill = new PatternFill { PatternType = PatternValues.Gray125 } });
+ fills.Count = 2;
+ }
+
+ if (fonts.Count == 0)
+ {
+ fonts.Append(new Font());
+ fonts.Count = 1;
+ }
+
+ if (borders.Count == 0)
+ {
+ borders.Append(new Border());
+ borders.Count = 1;
+ }
+
+ if (cellFormats.Count == 0)
+ {
+ cellFormats.Append(new CellFormat());
+ cellFormats.Count = 1;
+ }
+
+ var yellowFill = new Fill
+ {
+ PatternFill = new PatternFill
+ {
+ PatternType = PatternValues.Solid,
+ ForegroundColor = new ForegroundColor { Rgb = YellowHeaderRgb },
+ BackgroundColor = new BackgroundColor { Indexed = 64 }
+ }
+ };
+ fills.Append(yellowFill);
+ var yellowFillId = (uint)fills.ChildElements.Count - 1;
+ fills.Count = (uint)fills.ChildElements.Count;
+
+ var headerFont = new Font(new Bold());
+ fonts.Append(headerFont);
+ var headerFontId = (uint)fonts.ChildElements.Count - 1;
+ fonts.Count = (uint)fonts.ChildElements.Count;
+
+ var headerFormat = new CellFormat
+ {
+ FontId = headerFontId,
+ FillId = yellowFillId,
+ BorderId = 0,
+ ApplyFont = true,
+ ApplyFill = true
+ };
+ cellFormats.Append(headerFormat);
+ var headerFormatIndex = (uint)cellFormats.ChildElements.Count - 1;
+ cellFormats.Count = (uint)cellFormats.ChildElements.Count;
+
+ stylesheet.Save();
+ return headerFormatIndex;
+ }
+
+ private static WorksheetPart? GetWorksheetPart(WorkbookPart workbookPart, string sheetName)
+ {
+ var sheet = workbookPart.Workbook.Sheets?.Elements()
+ .FirstOrDefault(x => string.Equals(x.Name?.Value, sheetName, StringComparison.Ordinal));
+ if (sheet?.Id?.Value == null)
+ {
+ return null;
+ }
+
+ return workbookPart.GetPartById(sheet.Id.Value) as WorksheetPart;
+ }
+
+ private static Cell CreateCell(Row row, string cellReference, string text)
+ {
+ var cell = new Cell
+ {
+ CellReference = cellReference,
+ DataType = CellValues.String,
+ CellValue = new CellValue(text)
+ };
+ row.Append(cell);
+ return cell;
+ }
+
+ private static Row CreateRow(SheetData sheetData, uint rowIndex)
+ {
+ var row = new Row { RowIndex = rowIndex };
+ sheetData.Append(row);
+ return row;
+ }
+
+ private static void SetCellText(Row row, string cellReference, string text)
+ {
+ var cell = row.Elements| ().FirstOrDefault(x => x.CellReference?.Value == cellReference)
+ ?? CreateCell(row, cellReference, text);
+ cell.DataType = CellValues.String;
+ cell.CellValue = new CellValue(text);
+ }
+
+ private static string ToColumnName(int columnIndex)
+ {
+ var dividend = columnIndex + 1;
+ var columnName = string.Empty;
+ while (dividend > 0)
+ {
+ var modulo = (dividend - 1) % 26;
+ columnName = Convert.ToChar('A' + modulo) + columnName;
+ dividend = (dividend - modulo) / 26;
+ }
+
+ return columnName;
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs
index 1f46143..a4e3721 100644
--- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs
+++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs
@@ -49,7 +49,7 @@ public static class OrderTemplateImportMapping
{
StandardTemplateKey = templateKey,
HeaderRowIndex = 1,
- DataStartRowIndex = 4,
+ DataStartRowIndex = 2,
Mappings = fields.Select(field => new OrderTemplateColumnMappingItem
{
StandardFieldKey = field.FieldKey,
| |