feat(order): 优化标准模板 Excel 生成并支持保存映射时选择标准模板
标准模板仅保留中文表头、必填标黄与枚举下拉;导入起始行改为第2行;保存自定义映射时可指定 standardTemplateKey。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9ab112a36e
commit
fc22d9c6c6
@ -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;
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
<PackageReference Include="Fake.ObjectMapping.AutoMapper" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Application" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="Fake.Rbac.Infrastructure" Version="$(FakePackageVersion)" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.0.2" />
|
||||
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -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<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var mappings = new List<OrderTemplateColumnMappingItem>();
|
||||
|
||||
@ -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<OrderTemplateColumnMappingItem> Mappings { get; set; } = [];
|
||||
|
||||
|
||||
@ -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<byte[]> BuildStandardTemplateAsync(
|
||||
string templateKey,
|
||||
@ -17,21 +17,13 @@ public static class OrderTemplateExcelGenerator
|
||||
IReadOnlyList<OrderTemplateDictOption>? dictOptions = null)
|
||||
{
|
||||
var fields = OrderTemplateFieldRegistry.GetFields(templateKey);
|
||||
var overrides = exampleOverrides ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
_ = exampleOverrides;
|
||||
|
||||
var requiredRow = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
var exampleRow = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
// 仅生成中文表头行(MiniExcel 第一行表头 + 第二行留空供用户填写)
|
||||
var emptyDataRow = new Dictionary<string, object?>(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<string, object?>
|
||||
@ -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<string, object>
|
||||
{
|
||||
[DataSheetName] = new List<Dictionary<string, object?>> { requiredRow, exampleRow },
|
||||
[DataSheetName] = new List<Dictionary<string, object?>> { 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<OrderTemplateDictOption> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,344 @@
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Spreadsheet;
|
||||
|
||||
namespace Oms.Application.Services.Orders.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// 为标准模板 Excel 增加表头必填标黄、枚举下拉等 OpenXML 增强。
|
||||
/// </summary>
|
||||
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<OrderTemplateFieldDefinition> fields,
|
||||
IReadOnlyList<OrderTemplateDictOption> 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<OrderTemplateFieldDefinition> fields,
|
||||
uint styleIndex)
|
||||
{
|
||||
var sheetData = worksheet.GetFirstChild<SheetData>()
|
||||
?? throw new InvalidOperationException("工作表数据为空");
|
||||
|
||||
var headerRow = sheetData.Elements<Row>().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<Cell>().FirstOrDefault(x => x.CellReference?.Value == cellReference)
|
||||
?? CreateCell(headerRow, cellReference, fields[columnIndex].Label);
|
||||
cell.StyleIndex = styleIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyColumnValidations(
|
||||
WorkbookPart workbookPart,
|
||||
WorksheetPart dataSheetPart,
|
||||
IReadOnlyList<OrderTemplateFieldDefinition> fields,
|
||||
IReadOnlyList<OrderTemplateDictOption> dictOptions)
|
||||
{
|
||||
var dictRangeByType = BuildDictTypeRanges(workbookPart, dictOptions);
|
||||
var validations = dataSheetPart.Worksheet.GetFirstChild<DataValidations>();
|
||||
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<StringValue>
|
||||
{
|
||||
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<string, string> 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<string, string> BuildDictTypeRanges(
|
||||
WorkbookPart workbookPart,
|
||||
IReadOnlyList<OrderTemplateDictOption> dictOptions)
|
||||
{
|
||||
var result = new Dictionary<string, string>(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>();
|
||||
sheetData?.RemoveAllChildren<Row>();
|
||||
sheetData ??= new SheetData();
|
||||
if (dictSheetPart.Worksheet.GetFirstChild<SheetData>() == 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<Row>().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<WorkbookStylesPart>();
|
||||
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<Sheet>()
|
||||
.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<Cell>().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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user