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,