From 9ab112a36efc92d7e1ef4caf158244aa589e2753 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 8 Jul 2026 16:42:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(order):=20=E4=B8=8B=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E6=98=A0=E5=B0=84=E4=B8=8E=20Excel=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=85=A5=E5=B0=8F=E5=8C=85=E5=88=9B=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增专线小包标准模板生成、自定义列映射 API,并将 /order/upload 改为按映射解析 Excel 后走 CreateParcelCoreAsync;列表查询时自动补齐标准模板种子数据。 Co-authored-by: Cursor --- sql/migrate_t_order_template_mapping.sql | 12 + ..._order_template_dedicated_small_parcel.sql | 20 + .../Dtos/Orders/OrderBatchPayloadDtos.cs | 2 + .../Dtos/Orders/OrderTemplateMappingDtos.cs | 110 +++++ .../Dtos/Orders/OrderTemplateOutput.cs | 15 + .../Batch/OrderBatchCreateActionHandler.cs | 2 +- .../Orders/OrderService.BatchCreate.cs | 13 +- .../Services/Orders/OrderService.Helpers.cs | 187 ++++---- .../Services/Orders/OrderService.Template.cs | 421 ++++++++++++++++++ .../Services/Orders/OrderService.cs | 2 + .../OrderTemplateColumnMappingModels.cs | 37 ++ .../Templates/OrderTemplateExcelDataRow.cs | 11 + .../Templates/OrderTemplateExcelGenerator.cs | 78 ++++ .../Templates/OrderTemplateExcelReader.cs | 149 +++++++ .../Templates/OrderTemplateFieldRegistry.cs | 153 +++++++ .../Templates/OrderTemplateHeaderMatcher.cs | 145 ++++++ .../Templates/OrderTemplateImportMapping.cs | 78 ++++ .../Templates/OrderTemplateRowMapper.cs | 263 +++++++++++ .../Shared/OmsFileUploadHelper.cs | 62 +++ .../OrdersAggregate/Entities/OrderTemplate.cs | 13 + 20 files changed, 1662 insertions(+), 111 deletions(-) create mode 100644 sql/migrate_t_order_template_mapping.sql create mode 100644 sql/seed_order_template_dedicated_small_parcel.sql create mode 100644 src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.Template.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelDataRow.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelReader.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs create mode 100644 src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs diff --git a/sql/migrate_t_order_template_mapping.sql b/sql/migrate_t_order_template_mapping.sql new file mode 100644 index 0000000..c7d4a3b --- /dev/null +++ b/sql/migrate_t_order_template_mapping.sql @@ -0,0 +1,12 @@ +-- 下单模板:自定义映射与样例文件 +ALTER TABLE `t_order_template` + ADD COLUMN `StandardTemplateKey` VARCHAR(64) NULL COMMENT '映射所依据的标准模板 TemplateKey' AFTER `TemplateKey`, + ADD COLUMN `MappingStatus` VARCHAR(16) NULL COMMENT 'draft/ready;标准模板为 ready' AFTER `StandardTemplateKey`, + ADD COLUMN `SourceFileName` VARCHAR(256) NULL COMMENT '客户上传样例文件名' AFTER `MappingStatus`, + ADD COLUMN `SourceFilePath` VARCHAR(512) NULL COMMENT '样例文件相对路径 /uploads/...' AFTER `SourceFileName`, + ADD COLUMN `SourceHeadersJson` TEXT NULL COMMENT '客户 Excel 表头 JSON 数组' AFTER `SourceFilePath`, + ADD COLUMN `ColumnMappingJson` TEXT NULL COMMENT '标准字段与客户列映射 JSON' AFTER `SourceHeadersJson`; + +UPDATE `t_order_template` +SET `MappingStatus` = 'ready' +WHERE `Type` = 'standard' AND (`MappingStatus` IS NULL OR `MappingStatus` = ''); diff --git a/sql/seed_order_template_dedicated_small_parcel.sql b/sql/seed_order_template_dedicated_small_parcel.sql new file mode 100644 index 0000000..93159e5 --- /dev/null +++ b/sql/seed_order_template_dedicated_small_parcel.sql @@ -0,0 +1,20 @@ +-- 标准模板:专线小包模板 +INSERT INTO `t_order_template` ( + `Name`, `Type`, `TemplateKey`, `StandardTemplateKey`, `MappingStatus`, + `CustomerId`, `IsDeleted`, `CreateUserId`, `CreateTime` +) +SELECT + '专线小包模板', + 'standard', + 'dedicated_small_parcel_v1', + 'dedicated_small_parcel_v1', + 'ready', + NULL, + 0, + 0, + NOW() +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_order_template` + WHERE `Type` = 'standard' AND `TemplateKey` = 'dedicated_small_parcel_v1' AND `IsDeleted` = 0 +); diff --git a/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs b/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs index c987d52..0b2d781 100644 --- a/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs +++ b/src/Oms.Application/Dtos/Orders/OrderBatchPayloadDtos.cs @@ -38,6 +38,8 @@ public class OrderBatchImportRowPayload public int CustomerId { get; set; } + public int TemplateId { get; set; } + public string RowDataJson { get; set; } = string.Empty; } diff --git a/src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs b/src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs new file mode 100644 index 0000000..0c2f531 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Http; +using Oms.Application.Services.Orders.Templates; + +namespace Oms.Application.Dtos.Orders; + +public class OrderTemplateFieldSchemaOutput +{ + public string TemplateKey { get; set; } = string.Empty; + + public string TemplateName { get; set; } = string.Empty; + + public List Fields { get; set; } = []; +} + +public class OrderTemplateFieldSchemaItemOutput +{ + public string FieldKey { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + + public bool Required { get; set; } + + public string DataType { get; set; } = "string"; + + public string? DictType { get; set; } + + public string Group { get; set; } = string.Empty; + + public string Example { get; set; } = string.Empty; +} + +public class OrderTemplateDictOptionsOutput +{ + public List Items { get; set; } = []; +} + +public class OrderTemplateDictOptionOutput +{ + public string DictType { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; +} + +public class OrderTemplateCustomUploadInput +{ + public string Name { get; set; } = string.Empty; + + public IFormFile File { get; set; } = null!; +} + +public class OrderTemplateCustomUploadOutput +{ + public int TemplateId { get; set; } + + public string Name { get; set; } = string.Empty; + + public string MappingStatus { get; set; } = OrderTemplateMappingStatus.Draft; + + public List SourceHeaders { get; set; } = []; +} + +public class OrderTemplateCustomDetailOutput +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Type { get; set; } = string.Empty; + + public string? StandardTemplateKey { get; set; } + + public string MappingStatus { get; set; } = OrderTemplateMappingStatus.Draft; + + public string? SourceFileName { get; set; } + + public List SourceHeaders { get; set; } = []; + + public OrderTemplateColumnMappingDocument? Mapping { get; set; } + + public List StandardFields { get; set; } = []; +} + +public class OrderTemplateSaveMappingInput +{ + public int TemplateId { get; set; } + + public int HeaderRowIndex { get; set; } = 1; + + public int DataStartRowIndex { get; set; } = 2; + + public List Mappings { get; set; } = []; +} + +public class OrderTemplateSaveMappingItemInput +{ + public string StandardFieldKey { get; set; } = string.Empty; + + public string SourceColumn { get; set; } = string.Empty; +} + +public class OrderTemplateSaveMappingOutput +{ + public int TemplateId { get; set; } + + public string MappingStatus { get; set; } = OrderTemplateMappingStatus.Ready; + + public List MissingRequiredFields { get; set; } = []; +} diff --git a/src/Oms.Application/Dtos/Orders/OrderTemplateOutput.cs b/src/Oms.Application/Dtos/Orders/OrderTemplateOutput.cs index 50c5b53..e8c054c 100644 --- a/src/Oms.Application/Dtos/Orders/OrderTemplateOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderTemplateOutput.cs @@ -39,4 +39,19 @@ public class OrderTemplateOutput /// 更新时间。 /// public DateTime? UpdateTime { get; set; } + + /// + /// 映射状态:draft / ready。 + /// + public string? MappingStatus { get; set; } + + /// + /// 所依据的标准模板 Key。 + /// + public string? StandardTemplateKey { get; set; } + + /// + /// 客户样例文件名(自定义模板)。 + /// + public string? SourceFileName { get; set; } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchCreateActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchCreateActionHandler.cs index b94244b..e1ca238 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchCreateActionHandler.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchCreateActionHandler.cs @@ -32,7 +32,7 @@ public sealed class OrderBatchCreateActionHandler(IServiceScopeFactory scopeFact { using var scope = scopeFactory.CreateScope(); var orderService = scope.ServiceProvider.GetRequiredService(); - await orderService.ImportRowForBatchAsync(payload, cancellationToken); + await orderService.ImportRowForBatchAsync(payload, message.OperatorUserId, cancellationToken); return OrderBatchActionExecuteResult.Ok(); } catch (BusinessException ex) diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs b/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs index da283be..7534d13 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using Oms.Application.Dtos.Orders; using Oms.Application.Messaging; using Oms.Application.Services.Orders.Batch; +using Oms.Application.Services.Orders.Templates; using Oms.Domain.OrdersAggregate.Consts; namespace Oms.Application.Services.Orders; @@ -17,11 +18,19 @@ public partial class OrderService /// public async Task ImportRowForBatchAsync( OrderBatchImportRowPayload payload, + int operatorUserId, CancellationToken cancellationToken = default) { - var rowDict = JsonConvert.DeserializeObject>(payload.RowDataJson) + var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == payload.TemplateId, cancellationToken) + ?? throw new BusinessException("模板不存在"); + + OrderTemplateImportMapping.EnsureTemplateReadyForImport(template); + var mapping = OrderTemplateImportMapping.Resolve(template); + var sourceValues = JsonConvert.DeserializeObject>(payload.RowDataJson) ?? throw new BusinessException("行数据无效"); - await CreateImportOrderAsync(rowDict, payload.CustomerId); + + var input = OrderTemplateRowMapper.MapToParcelCreateInput(sourceValues, mapping, payload.CustomerId); + await CreateParcelForBatchAsync(input, operatorUserId, cancellationToken); } /// diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 99e2337..a3e3e23 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -10,7 +10,7 @@ using Oms.Application.DataPerm; using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Messaging; -using Oms.Application.Services.Orders.Batch; +using Oms.Application.Services.Orders.Templates; using Oms.Application.Shared; using Oms.Domain.OrdersAggregate; using Oms.Domain.ChannelsAggregate.Consts; @@ -205,6 +205,12 @@ public partial class OrderService private async Task> GetTemplateOutputsAsync(OrderTemplateListInput input) { + if (string.IsNullOrWhiteSpace(input.Type) + || string.Equals(input.Type, "standard", StringComparison.OrdinalIgnoreCase)) + { + await EnsureDedicatedSmallParcelStandardTemplateAsync(); + } + var page = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var keyword = input.Keyword?.Trim(); @@ -235,6 +241,11 @@ public partial class OrderService Type = x.Type, TemplateKey = x.TemplateKey, CustomerId = x.CustomerId, + MappingStatus = string.Equals(x.Type, "standard", StringComparison.OrdinalIgnoreCase) + ? OrderTemplateMappingStatus.Ready + : x.MappingStatus, + StandardTemplateKey = x.StandardTemplateKey ?? x.TemplateKey, + SourceFileName = x.SourceFileName, CreateTime = x.CreateTime, UpdateTime = OmsDocHelper.NullIfDefault(x.UpdateTime) }).ToList(), @@ -259,39 +270,39 @@ public partial class OrderService EnsureTemplateAccessible(template, customerId.Value); } - var rows = new[] + if (string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase)) { - new Dictionary + if (string.IsNullOrWhiteSpace(template.SourceFilePath)) { - ["ClientNo"] = "", - ["TrackNo"] = "", - ["ServiceNo"] = "", - ["ProductCode"] = "", - ["ChannelCode"] = "", - ["CargoAttribute"] = CargoAttributeCodes.General, - ["WarehouseId"] = "", - ["CustomerId"] = customerId ?? template.CustomerId, - ["Length"] = "", - ["Width"] = "", - ["Height"] = "", - ["ActualWeight"] = "", - ["VolumetricWeight"] = "", - ["ChargeableWeight"] = "", - ["Cubic"] = "", - ["IsCod"] = "", - ["CodCurrency"] = "", - ["CodSum"] = "", - ["DeclaredCurrency"] = "", - ["DeclaredSum"] = "", - ["Quantity"] = "" + throw new BusinessException("自定义模板样例文件不存在,请重新上传"); } - }; - await using var stream = new MemoryStream(); - await MiniExcel.SaveAsAsync(stream, rows); - stream.Position = 0; - return new FileContentResult(stream.ToArray(), - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + var absolutePath = OmsFileUploadHelper.ResolveWebRootAbsolutePath(WebHostEnvironment, template.SourceFilePath); + if (!File.Exists(absolutePath)) + { + throw new BusinessException("自定义模板样例文件不存在,请重新上传"); + } + + var bytes = await File.ReadAllBytesAsync(absolutePath); + var downloadName = string.IsNullOrWhiteSpace(template.SourceFileName) + ? $"{template.Name}.xlsx" + : template.SourceFileName; + return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = downloadName + }; + } + + var templateKey = template.TemplateKey ?? template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel; + var exampleOverrides = await BuildTemplateExampleOverridesAsync(customerId); + var dictOptions = await LoadTemplateDictOptionsAsync( + ["sales_product", "cargo_attribute", "export_customs_mode", "currency", "warehouse"]); + var fileBytes = await OrderTemplateExcelGenerator.BuildStandardTemplateAsync( + templateKey, + exampleOverrides, + dictOptions); + + return new FileContentResult(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") { FileDownloadName = $"{template.Name}-{DateTime.Now:yyyyMMddHHmmss}.xlsx" }; @@ -322,21 +333,11 @@ public partial class OrderService var effectiveCustomer = await ResolveEffectiveCustomerAsync(); EnsureTemplateAccessible(template, effectiveCustomer.Id); + OrderTemplateImportMapping.EnsureTemplateReadyForImport(template); - var parsedRows = new List<(int RowNumber, IDictionary RowDict)>(); - var rowNumber = 1; - var rows = await MiniExcel.QueryAsync(input.File!.OpenReadStream(), useHeaderRow: true); - foreach (var row in rows) - { - rowNumber++; - if (row is not IDictionary rowDict) - { - parsedRows.Add((rowNumber, new Dictionary())); - continue; - } - - parsedRows.Add((rowNumber, rowDict)); - } + var mapping = OrderTemplateImportMapping.Resolve(template); + await using var stream = input.File!.OpenReadStream(); + var parsedRows = await OrderTemplateExcelReader.ReadDataRowsAsync(stream, mapping); if (parsedRows.Count == 0) { @@ -366,18 +367,23 @@ public partial class OrderService CustomerId = effectiveCustomer.Id, }); - var payloadItems = parsedRows.Select(row => new OrderBatchPayloadItemInput + var payloadItems = parsedRows.Select(row => { - OrderId = 0, - ServiceNo = GetStringCell(row.RowDict, "ServiceNo"), - ItemKey = $"import:{task.Id}:{row.RowNumber}", - PayloadJson = JsonConvert.SerializeObject(new OrderBatchImportRowPayload + var parcelInput = OrderTemplateRowMapper.MapToParcelCreateInput(row.SourceValues, mapping, effectiveCustomer.Id); + return new OrderBatchPayloadItemInput { - TaskId = task.Id, - RowNumber = row.RowNumber, - CustomerId = effectiveCustomer.Id, - RowDataJson = JsonConvert.SerializeObject(row.RowDict), - }), + OrderId = 0, + ServiceNo = parcelInput.ServiceNo, + ItemKey = $"import:{task.Id}:{row.RowNumber}", + PayloadJson = JsonConvert.SerializeObject(new OrderBatchImportRowPayload + { + TaskId = task.Id, + RowNumber = row.RowNumber, + CustomerId = effectiveCustomer.Id, + TemplateId = template.Id, + RowDataJson = JsonConvert.SerializeObject(row.SourceValues), + }), + }; }).ToList(); var job = await BatchJobCoordinator.CreatePayloadJobAsync( @@ -409,6 +415,11 @@ public partial class OrderService var effectiveCustomer = await ResolveEffectiveCustomerAsync(); EnsureTemplateAccessible(template, effectiveCustomer.Id); + OrderTemplateImportMapping.EnsureTemplateReadyForImport(template); + + var mapping = OrderTemplateImportMapping.Resolve(template); + await using var stream = input.File!.OpenReadStream(); + var parsedRows = await OrderTemplateExcelReader.ReadDataRowsAsync(stream, mapping); var task = await OrderTaskRepository.InsertAsync(new OrderTask { @@ -424,33 +435,34 @@ public partial class OrderService var quantity = 0; var successCount = 0; var failCount = 0; - var rowNumber = 1; var failList = new List(); - var rows = await MiniExcel.QueryAsync(input.File!.OpenReadStream(), useHeaderRow: true); - foreach (var row in rows) + foreach (var row in parsedRows) { - rowNumber++; quantity++; - if (row is not IDictionary rowDict) - { - failCount++; - failList.Add(BuildTaskFail(task.Id, rowNumber, "无法解析Excel行数据", row)); - continue; - } - try { - await CreateImportOrderAsync(rowDict, effectiveCustomer.Id); + var parcelInput = OrderTemplateRowMapper.MapToParcelCreateInput( + row.SourceValues, + mapping, + effectiveCustomer.Id); + await CreateParcelCoreAsync(parcelInput); successCount++; } catch (Exception ex) { failCount++; - failList.Add(BuildTaskFail(task.Id, rowNumber, ex.Message, rowDict)); + failList.Add(BuildTaskFail(task.Id, row.RowNumber, ex.Message, row.SourceValues)); } } + if (parsedRows.Count == 0) + { + quantity = 1; + failCount = 1; + failList.Add(BuildTaskFail(task.Id, 1, "Excel 中未读取到可用数据", new Dictionary())); + } + if (failList.Count > 0) { await OrderTaskRepository.InsertFailListAsync(failList); @@ -464,47 +476,6 @@ public partial class OrderService return new OrderUploadOutput { TaskId = task.Id }; } - private async Task CreateImportOrderAsync(IDictionary rowDict, int effectiveCustomerId) - { - var rowCustomerId = GetIntCell(rowDict, "CustomerId"); - if (rowCustomerId is > 0 && rowCustomerId.Value != effectiveCustomerId) - { - throw new BusinessException("客户ID非法"); - } - - var serviceNo = GetStringCell(rowDict, "ServiceNo"); - var resolvedServiceNo = await ResolveServiceNoAsync(serviceNo); - - var order = await OrderRepository.InsertAsync(new Order - { - ServiceNo = resolvedServiceNo, - ClientNo = GetStringCell(rowDict, "ClientNo"), - TrackNo = string.IsNullOrWhiteSpace(GetStringCell(rowDict, "TrackNo")) - ? null - : GetStringCell(rowDict, "TrackNo"), - ProductCode = GetStringCell(rowDict, "ProductCode"), - ChannelCode = GetStringCell(rowDict, "ChannelCode"), - CargoAttribute = GetStringCell(rowDict, "CargoAttribute") ?? CargoAttributeCodes.General, - WarehouseId = GetIntCell(rowDict, "WarehouseId"), - CustomerId = effectiveCustomerId, - CodCurrency = GetStringCell(rowDict, "CodCurrency"), - CodSum = GetDecimalCell(rowDict, "CodSum"), - DeclaredCurrency = GetStringCell(rowDict, "DeclaredCurrency"), - DeclaredSum = GetDecimalCell(rowDict, "DeclaredSum"), - Quantity = GetIntCell(rowDict, "Quantity"), - IsCod = GetBoolCell(rowDict, "IsCod") ? (byte)1 : (byte)0, - IsIntercept = 0, - IsRemote = 0, - OrderStatus = OrderStatusCodes.PendingPayment - }); - - var weight = BuildForecastWeight(order.Id, rowDict); - if (weight != null) - { - await OrderRepository.InsertWeightsAsync([weight]); - } - } - private async Task> GetTaskOutputsAsync(OrderTaskListInput input) { var page = OmsDocHelper.NormalizePage(input.Page); diff --git a/src/Oms.Application/Services/Orders/OrderService.Template.cs b/src/Oms.Application/Services/Orders/OrderService.Template.cs new file mode 100644 index 0000000..4f6b4e0 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.Template.cs @@ -0,0 +1,421 @@ +using System.Text.Json; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders.Templates; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + /// + /// 获取标准模板字段定义。 + /// + [HttpGet("/order/template/field-schema")] + public Task GetTemplateFieldSchemaAsync([FromQuery] string? templateKey) + { + var key = string.IsNullOrWhiteSpace(templateKey) ? OrderTemplateKeys.DedicatedSmallParcel : templateKey.Trim(); + var fields = OrderTemplateFieldRegistry.GetFields(key); + return Task.FromResult(new OrderTemplateFieldSchemaOutput + { + TemplateKey = key, + TemplateName = key == OrderTemplateKeys.DedicatedSmallParcel ? "专线小包模板" : key, + Fields = fields.Select(MapFieldSchemaItem).ToList() + }); + } + + /// + /// 获取模板下拉字典选项。 + /// + [HttpGet("/order/template/dict-options")] + public async Task GetTemplateDictOptionsAsync([FromQuery] string? dictTypes) + { + var types = (dictTypes ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var items = await LoadTemplateDictOptionsAsync(types); + return new OrderTemplateDictOptionsOutput { Items = items.Select(x => new OrderTemplateDictOptionOutput + { + DictType = x.DictType, + Code = x.Code, + Label = x.Label + }).ToList() }; + } + + /// + /// 上传自定义模板样例并创建草稿映射。 + /// + [HttpPost("/order/template/custom/upload")] + public async Task UploadCustomTemplateAsync([FromForm] OrderTemplateCustomUploadInput input) + { + if (input.File == null || input.File.Length == 0) + { + throw new BusinessException("上传文件不能为空"); + } + + var name = input.Name?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new BusinessException("模板名称不能为空"); + } + + var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(CustomerRepository, CurrentUser); + var customerId = customer.Id; + await using var headerStream = input.File.OpenReadStream(); + var headers = OrderTemplateHeaderMatcher.ReadHeaderRow(headerStream); + if (headers.Count == 0) + { + throw new BusinessException("未能识别 Excel 表头,请确认第一行为列名"); + } + + var (relativePath, originalName) = await OmsFileUploadHelper.SaveOrderTemplateSampleAsync( + input.File, + customerId, + WebHostEnvironment); + + var suggested = OrderTemplateHeaderMatcher.BuildSuggestedMapping(OrderTemplateKeys.DedicatedSmallParcel, headers); + var entity = new OrderTemplate + { + Name = name, + Type = "custom", + TemplateKey = $"custom_{Guid.NewGuid():N}".Substring(0, 24), + StandardTemplateKey = OrderTemplateKeys.DedicatedSmallParcel, + MappingStatus = OrderTemplateMappingStatus.Draft, + CustomerId = customerId, + SourceFileName = originalName, + SourceFilePath = relativePath, + SourceHeadersJson = JsonSerializer.Serialize(headers), + ColumnMappingJson = JsonSerializer.Serialize(suggested) + }; + await OrderTemplateRepository.InsertAsync(entity); + + return new OrderTemplateCustomUploadOutput + { + TemplateId = entity.Id, + Name = entity.Name, + MappingStatus = entity.MappingStatus ?? OrderTemplateMappingStatus.Draft, + SourceHeaders = headers + }; + } + + /// + /// 获取自定义模板详情(含映射)。 + /// + [HttpGet("/order/template/custom/detail")] + public async Task GetCustomTemplateDetailAsync([FromQuery] int templateId) + { + var template = await GetAccessibleTemplateOrThrowAsync(templateId); + if (!string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("仅自定义模板支持映射配置"); + } + + var standardKey = template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel; + return new OrderTemplateCustomDetailOutput + { + Id = template.Id, + Name = template.Name, + Type = template.Type, + StandardTemplateKey = standardKey, + MappingStatus = template.MappingStatus ?? OrderTemplateMappingStatus.Draft, + SourceFileName = template.SourceFileName, + SourceHeaders = DeserializeStringList(template.SourceHeadersJson), + Mapping = DeserializeMapping(template.ColumnMappingJson), + StandardFields = OrderTemplateFieldRegistry.GetFields(standardKey).Select(MapFieldSchemaItem).ToList() + }; + } + + /// + /// 保存自定义模板字段映射。 + /// + [HttpPost("/order/template/custom/save-mapping")] + public async Task SaveCustomTemplateMappingAsync([FromBody] OrderTemplateSaveMappingInput input) + { + var template = await GetAccessibleTemplateOrThrowAsync(input.TemplateId); + if (!string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("仅自定义模板支持映射配置"); + } + + var sourceHeaders = DeserializeStringList(template.SourceHeadersJson); + if (sourceHeaders.Count == 0) + { + throw new BusinessException("模板表头为空,请重新上传样例文件"); + } + + var standardKey = template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel; + var headerSet = sourceHeaders.ToHashSet(StringComparer.OrdinalIgnoreCase); + var usedColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + var mappings = new List(); + + foreach (var item in input.Mappings ?? []) + { + var fieldKey = item.StandardFieldKey?.Trim(); + var sourceColumn = item.SourceColumn?.Trim(); + if (string.IsNullOrWhiteSpace(fieldKey) || string.IsNullOrWhiteSpace(sourceColumn)) + { + continue; + } + + if (!headerSet.Contains(sourceColumn)) + { + throw new BusinessException($"客户列「{sourceColumn}」不存在于上传模板表头中"); + } + + if (!usedColumns.Add(sourceColumn)) + { + throw new BusinessException($"客户列「{sourceColumn}」不能重复映射"); + } + + var field = OrderTemplateFieldRegistry.TryGetField(standardKey, fieldKey); + if (field == null) + { + throw new BusinessException($"未知标准字段:{fieldKey}"); + } + + mappings.Add(new OrderTemplateColumnMappingItem + { + StandardFieldKey = field.FieldKey, + SourceColumn = sourceColumn, + Required = field.Required + }); + } + + var mappedColumns = mappings.Select(x => x.SourceColumn).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missingRequired = OrderTemplateFieldRegistry.GetFields(standardKey) + .Where(f => f.Required) + .Where(f => mappings.All(m => !string.Equals(m.StandardFieldKey, f.FieldKey, StringComparison.OrdinalIgnoreCase))) + .Select(f => f.Label) + .ToList(); + + var mappingDoc = new OrderTemplateColumnMappingDocument + { + StandardTemplateKey = standardKey, + HeaderRowIndex = input.HeaderRowIndex <= 0 ? 1 : input.HeaderRowIndex, + DataStartRowIndex = input.DataStartRowIndex <= 0 ? 2 : input.DataStartRowIndex, + Mappings = mappings, + UnmappedSourceColumns = sourceHeaders.Where(h => !mappedColumns.Contains(h)).ToList() + }; + + template.ColumnMappingJson = JsonSerializer.Serialize(mappingDoc); + template.MappingStatus = missingRequired.Count == 0 + ? OrderTemplateMappingStatus.Ready + : OrderTemplateMappingStatus.Draft; + await OrderTemplateRepository.UpdateAsync(template); + + return new OrderTemplateSaveMappingOutput + { + TemplateId = template.Id, + MappingStatus = template.MappingStatus ?? OrderTemplateMappingStatus.Draft, + MissingRequiredFields = missingRequired + }; + } + + /// + /// 删除自定义模板。 + /// + [HttpDelete("/order/template/custom/{templateId:int}")] + public async Task DeleteCustomTemplateAsync([FromRoute] int templateId) + { + var template = await GetAccessibleTemplateOrThrowAsync(templateId); + if (!string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("标准模板不可删除"); + } + + await OrderTemplateRepository.DeleteAsync(template); + } + + /// + /// 列表查询前确保标准「专线小包模板」存在(未执行种子脚本时自动补齐)。 + /// + private async Task EnsureDedicatedSmallParcelStandardTemplateAsync() + { + var existing = await OrderTemplateRepository.FirstOrDefaultAsync(x => + x.Type == "standard" && x.TemplateKey == OrderTemplateKeys.DedicatedSmallParcel); + if (existing != null) + { + return; + } + + await OrderTemplateRepository.InsertAsync(new OrderTemplate + { + Name = "专线小包模板", + Type = "standard", + TemplateKey = OrderTemplateKeys.DedicatedSmallParcel, + StandardTemplateKey = OrderTemplateKeys.DedicatedSmallParcel, + MappingStatus = OrderTemplateMappingStatus.Ready, + CustomerId = null, + }); + } + + private async Task GetAccessibleTemplateOrThrowAsync(int templateId) + { + var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId); + if (template == null) + { + throw new BusinessException("模板不存在"); + } + + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(CustomerRepository, CurrentUser, null); + if (customerId.HasValue) + { + EnsureTemplateAccessible(template, customerId.Value); + } + + return template; + } + + private static OrderTemplateFieldSchemaItemOutput MapFieldSchemaItem(OrderTemplateFieldDefinition field) => new() + { + FieldKey = field.FieldKey, + Label = field.Label, + Required = field.Required, + DataType = field.DataType, + DictType = field.DictType, + Group = field.Group, + Example = field.Example + }; + + private static List DeserializeStringList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json) ?? []; + } + catch + { + return []; + } + } + + private static OrderTemplateColumnMappingDocument? DeserializeMapping(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json); + } + catch + { + return null; + } + } + + private async Task> LoadTemplateDictOptionsAsync(IReadOnlyCollection dictTypes) + { + var result = new List(); + if (dictTypes.Count == 0) + { + dictTypes = ["cargo_attribute", "export_customs_mode", "currency"]; + } + + foreach (var dictType in dictTypes) + { + if (string.Equals(dictType, "sales_product", StringComparison.OrdinalIgnoreCase)) + { + var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled); + result.AddRange(products + .OrderBy(x => x.ProductCode) + .Select(x => new OrderTemplateDictOption + { + DictType = "sales_product", + Code = x.ProductCode, + Label = string.IsNullOrWhiteSpace(x.ProductName) ? x.ProductCode : $"{x.ProductName} {x.ProductCode}" + })); + continue; + } + + if (string.Equals(dictType, "warehouse", StringComparison.OrdinalIgnoreCase)) + { + var warehouses = await WarehouseAddressRepository.GetListAsync(_ => true); + result.AddRange(warehouses + .OrderBy(x => x.Id) + .Select(x => new OrderTemplateDictOption + { + DictType = "warehouse", + Code = x.Id.ToString(), + Label = x.WarehouseName + })); + continue; + } + + var dictRows = await DictRepository.GetListAsync(x => x.DictType == dictType); + result.AddRange(dictRows + .OrderBy(x => x.DictSort) + .ThenBy(x => x.DictCode) + .Select(x => new OrderTemplateDictOption + { + DictType = dictType, + Code = x.DictCode, + Label = string.IsNullOrWhiteSpace(x.DictName) ? x.DictCode : x.DictName + })); + } + + return result; + } + + private async Task> BuildTemplateExampleOverridesAsync(int? customerId) + { + var overrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled); + var firstProduct = products.OrderBy(x => x.ProductCode).FirstOrDefault(); + if (firstProduct != null) + { + overrides["productCode"] = firstProduct.ProductCode; + } + + var warehouses = await WarehouseAddressRepository.GetListAsync(_ => true); + var firstWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault(); + if (firstWarehouse != null) + { + overrides["warehouseId"] = firstWarehouse.Id.ToString(); + } + + var cargoDict = await DictRepository.GetListAsync(x => x.DictType == "cargo_attribute"); + var firstCargo = cargoDict.OrderBy(x => x.DictSort).FirstOrDefault(); + if (firstCargo != null) + { + overrides["cargoAttribute"] = firstCargo.DictCode; + } + + var currencyDict = await DictRepository.GetListAsync(x => x.DictType == "currency"); + var firstCurrency = currencyDict.OrderBy(x => x.DictSort).FirstOrDefault(); + if (firstCurrency != null) + { + overrides["declaredCurrency"] = firstCurrency.DictCode; + overrides["codCurrency"] = firstCurrency.DictCode; + } + + var exportDict = await DictRepository.GetListAsync(x => x.DictType == "export_customs_mode"); + var export9610 = exportDict.FirstOrDefault(x => + string.Equals(x.DictCode, "9610Simple", StringComparison.OrdinalIgnoreCase) || + x.DictName.Contains("9610", StringComparison.OrdinalIgnoreCase)); + if (export9610 != null) + { + overrides["exportCustomsMode"] = export9610.DictCode; + } + + if (customerId.HasValue) + { + overrides["clientNo"] = $"C{DateTime.Now:yyyyMMdd}{Random.Shared.Next(100000, 999999)}"; + } + + return overrides; + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index b586715..8a08e91 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -15,6 +15,7 @@ using Oms.Domain.OrdersAggregate; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Orders; @@ -30,6 +31,7 @@ public partial class OrderService : ApplicationService private IOrderTaskRepository OrderTaskRepository => LazyServiceProvider.GetRequiredService(); private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); + private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => LazyServiceProvider.GetRequiredService(); diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs new file mode 100644 index 0000000..4e43560 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateColumnMappingModels.cs @@ -0,0 +1,37 @@ +namespace Oms.Application.Services.Orders.Templates; + +/// +/// 自定义模板列映射 JSON。 +/// +public class OrderTemplateColumnMappingDocument +{ + public string StandardTemplateKey { get; set; } = OrderTemplateKeys.DedicatedSmallParcel; + + public int Version { get; set; } = 1; + + public int HeaderRowIndex { get; set; } = 1; + + public int DataStartRowIndex { get; set; } = 4; + + public List Mappings { get; set; } = []; + + public List UnmappedSourceColumns { get; set; } = []; +} + +public class OrderTemplateColumnMappingItem +{ + public string StandardFieldKey { get; set; } = string.Empty; + + public string SourceColumn { get; set; } = string.Empty; + + public bool Required { get; set; } +} + +public class OrderTemplateDictOption +{ + public string DictType { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelDataRow.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelDataRow.cs new file mode 100644 index 0000000..07c477d --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelDataRow.cs @@ -0,0 +1,11 @@ +namespace Oms.Application.Services.Orders.Templates; + +/// +/// Excel 解析后的单行源数据(列名 -> 单元格值)。 +/// +public sealed class OrderTemplateExcelDataRow +{ + public int RowNumber { get; init; } + + public Dictionary SourceValues { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs new file mode 100644 index 0000000..64b3dea --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelGenerator.cs @@ -0,0 +1,78 @@ +using MiniExcelLibs; + +namespace Oms.Application.Services.Orders.Templates; + +/// +/// 按标准字段注册表生成 Excel 模板(专线小包等)。 +/// +public static class OrderTemplateExcelGenerator +{ + public const string DataSheetName = "下单数据"; + private const string GuideSheetName = "字段说明"; + private const string DictSheetName = "下拉选项"; + + public static async Task BuildStandardTemplateAsync( + string templateKey, + IReadOnlyDictionary? exampleOverrides = null, + IReadOnlyList? dictOptions = null) + { + var fields = OrderTemplateFieldRegistry.GetFields(templateKey); + var overrides = exampleOverrides ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + var requiredRow = new Dictionary(StringComparer.Ordinal); + var exampleRow = 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; + } + } + + var guideRows = fields.Select(f => new Dictionary + { + ["列名"] = f.Label, + ["字段Key"] = f.FieldKey, + ["是否必填"] = f.Required ? "是" : "否", + ["数据类型"] = f.DataType, + ["字典类型"] = f.DictType ?? string.Empty, + ["分组"] = f.Group, + ["示例"] = f.Example + }).ToList(); + + var dictRows = (dictOptions ?? []) + .Select(x => new Dictionary + { + ["字典类型"] = x.DictType, + ["编码"] = x.Code, + ["显示名"] = x.Label + }) + .ToList(); + + if (dictRows.Count == 0) + { + dictRows.Add(new Dictionary + { + ["字典类型"] = string.Empty, + ["编码"] = string.Empty, + ["显示名"] = string.Empty + }); + } + + var sheets = new Dictionary + { + [DataSheetName] = new List> { requiredRow, exampleRow }, + [GuideSheetName] = guideRows, + [DictSheetName] = dictRows + }; + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, sheets); + return stream.ToArray(); + } +} diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelReader.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelReader.cs new file mode 100644 index 0000000..2584490 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateExcelReader.cs @@ -0,0 +1,149 @@ +using Fake.Application; +using MiniExcelLibs; + +namespace Oms.Application.Services.Orders.Templates; + +/// +/// 按模板列映射读取 Excel 数据行。 +/// +public static class OrderTemplateExcelReader +{ + public static async Task> ReadDataRowsAsync( + Stream stream, + OrderTemplateColumnMappingDocument mapping) + { + if (mapping.Mappings.Count == 0) + { + throw new BusinessException("模板列映射为空"); + } + + stream.Position = 0; + var sheetName = ResolveDataSheetName(stream); + stream.Position = 0; + + var allRows = (await MiniExcel.QueryAsync(stream, sheetName: sheetName, useHeaderRow: false)) + .Cast>() + .ToList(); + + if (allRows.Count < mapping.HeaderRowIndex) + { + throw new BusinessException("Excel 中未读取到表头行"); + } + + var headerRow = allRows[mapping.HeaderRowIndex - 1]; + var columnHeaders = headerRow + .OrderBy(kv => ColumnIndex(kv.Key)) + .ToDictionary( + kv => kv.Key, + kv => kv.Value?.ToString()?.Trim() ?? string.Empty, + StringComparer.OrdinalIgnoreCase); + + var mappedColumns = mapping.Mappings + .Select(x => x.SourceColumn) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var rows = new List(); + for (var rowIndex = mapping.DataStartRowIndex - 1; rowIndex < allRows.Count; rowIndex++) + { + var rawRow = allRows[rowIndex]; + if (IsRowEmpty(rawRow)) + { + continue; + } + + var sourceValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var column in columnHeaders) + { + if (string.IsNullOrWhiteSpace(column.Value)) + { + continue; + } + + if (!mappedColumns.Contains(column.Value)) + { + continue; + } + + if (!rawRow.TryGetValue(column.Key, out var cellValue) || cellValue == null) + { + continue; + } + + sourceValues[column.Value] = cellValue; + } + + if (!HasMappedValue(sourceValues, mappedColumns)) + { + continue; + } + + rows.Add(new OrderTemplateExcelDataRow + { + RowNumber = rowIndex + 1, + SourceValues = sourceValues + }); + } + + return rows; + } + + private static string? ResolveDataSheetName(Stream stream) + { + var sheetNames = MiniExcel.GetSheetNames(stream).ToList(); + if (sheetNames.Count == 0) + { + return null; + } + + return sheetNames.FirstOrDefault(x => string.Equals(x, OrderTemplateExcelGenerator.DataSheetName, StringComparison.Ordinal)) + ?? sheetNames[0]; + } + + private static bool HasMappedValue( + IReadOnlyDictionary sourceValues, + IReadOnlySet mappedColumns) + { + foreach (var column in mappedColumns) + { + if (!sourceValues.TryGetValue(column, out var value) || value == null) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(value.ToString())) + { + return true; + } + } + + return false; + } + + private static bool IsRowEmpty(IDictionary row) + { + return row.Values.All(value => string.IsNullOrWhiteSpace(value?.ToString())); + } + + private static int ColumnIndex(string columnKey) + { + if (string.IsNullOrWhiteSpace(columnKey)) + { + return int.MaxValue; + } + + var column = columnKey.Trim().ToUpperInvariant(); + var index = 0; + foreach (var ch in column) + { + if (ch is < 'A' or > 'Z') + { + return int.MaxValue; + } + + index = index * 26 + (ch - 'A' + 1); + } + + return index; + } +} diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs new file mode 100644 index 0000000..d743057 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs @@ -0,0 +1,153 @@ +namespace Oms.Application.Services.Orders.Templates; + +public static class OrderTemplateKeys +{ + public const string DedicatedSmallParcel = "dedicated_small_parcel_v1"; +} + +public static class OrderTemplateMappingStatus +{ + public const string Draft = "draft"; + public const string Ready = "ready"; +} + +public sealed class OrderTemplateFieldDefinition +{ + public string FieldKey { get; init; } = string.Empty; + public string Label { get; init; } = string.Empty; + public bool Required { get; init; } + public string DataType { get; init; } = "string"; + public string? DictType { get; init; } + public string Group { get; init; } = string.Empty; + public string Example { get; init; } = string.Empty; + public int Sort { get; init; } +} + +public static class OrderTemplateFieldRegistry +{ + private static readonly IReadOnlyList DedicatedSmallParcelFields = BuildDedicatedSmallParcelFields(); + + public static IReadOnlyList GetFields(string? templateKey) + { + var key = string.IsNullOrWhiteSpace(templateKey) ? OrderTemplateKeys.DedicatedSmallParcel : templateKey.Trim(); + return key switch + { + OrderTemplateKeys.DedicatedSmallParcel => DedicatedSmallParcelFields, + _ => DedicatedSmallParcelFields + }; + } + + public static OrderTemplateFieldDefinition? TryGetField(string? templateKey, string fieldKey) + { + if (string.IsNullOrWhiteSpace(fieldKey)) + { + return null; + } + + return GetFields(templateKey).FirstOrDefault(x => + string.Equals(x.FieldKey, fieldKey.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + private static IReadOnlyList BuildDedicatedSmallParcelFields() + { + var list = new List(); + var sort = 0; + void Add(string key, string label, bool required, string dataType = "string", string? dictType = null, string group = "basic", string example = "") + { + sort += 10; + list.Add(new OrderTemplateFieldDefinition + { + FieldKey = key, + Label = label, + Required = required, + DataType = dataType, + DictType = dictType, + Group = group, + Example = example, + Sort = sort + }); + } + + Add("requestId", "幂等请求ID", false); + Add("clientNo", "客户单号", true, example: "C202604230001"); + Add("trackNo", "跟踪号", false); + Add("serviceNo", "运单号", false); + Add("productCode", "运输方式", true, "select", "sales_product", "basic", "PROD001"); + Add("cargoAttribute", "货物属性", true, "select", "cargo_attribute", "basic", "general"); + Add("exportCustomsMode", "出口报关方式", false, "select", "export_customs_mode", "basic", "9610Simple"); + Add("warehouseId", "仓库", true, "select", "warehouse", "basic", "1"); + Add("length", "长(cm)", false, "number", example: "10"); + Add("width", "宽(cm)", false, "number", example: "10"); + Add("height", "高(cm)", false, "number", example: "10"); + Add("actualWeight", "实重(kg)", true, "number", example: "1.2"); + Add("isCod", "是否COD", false, "boolean", example: "false"); + Add("codCurrency", "COD币种", false, "select", "currency", example: "USD"); + Add("codSum", "COD金额", false, "number", example: "0"); + Add("declaredCurrency", "申报币种", true, "select", "currency", example: "USD"); + Add("declaredSum", "申报金额", false, "number"); + Add("quantity", "包裹数量", true, "number", example: "1"); + + Add("receiver.recipientsName", "收件人名称", true, group: "receiver", example: "Demo Receiver"); + Add("receiver.companyName", "收件人公司", false, group: "receiver", example: "Demo Receiver LLC"); + Add("receiver.phone", "收件人手机", true, group: "receiver", example: "13900139000"); + Add("receiver.telePhone", "收件人电话", false, group: "receiver"); + Add("receiver.email", "收件人邮箱", false, group: "receiver", example: "receiver@example.com"); + Add("receiver.countryCode", "收件国家代码", true, group: "receiver", example: "US"); + Add("receiver.province", "收件省/州", false, group: "receiver", example: "California"); + Add("receiver.city", "收件城市", false, group: "receiver", example: "Los Angeles"); + Add("receiver.streetNumber", "收件人门牌号", false, group: "receiver", example: "1001"); + Add("receiver.streetAddress1", "收件地址1", true, group: "receiver", example: "Sunset Blvd"); + Add("receiver.streetAddress2", "收件地址2", false, group: "receiver"); + Add("receiver.streetAddress3", "收件地址3", false, group: "receiver"); + Add("receiver.zipCode", "收件邮编", true, group: "receiver", example: "90001"); + Add("receiver.taxType", "税号类型", false, group: "receiver", example: "0"); + Add("receiver.taxNo", "税号", false, group: "receiver"); + Add("receiver.certificateType", "证件类型", false, group: "receiver", example: "0"); + Add("receiver.certificateNo", "证件号码", false, group: "receiver"); + Add("receiver.eori", "EORI", false, group: "receiver"); + Add("receiver.vat", "VAT", false, group: "receiver"); + + Add("sender.senderName", "发件人姓名", false, group: "sender", example: "Demo Sender"); + Add("sender.company", "发件人公司", false, group: "sender", example: "Demo Sender Co.,Ltd"); + Add("sender.phone", "发件人手机", false, group: "sender", example: "13800138000"); + Add("sender.telePhone", "发件人电话", false, group: "sender"); + Add("sender.email", "发件人邮箱", false, group: "sender", example: "sender@example.com"); + Add("sender.countryCode", "发件国家代码", false, group: "sender", example: "CN"); + Add("sender.province", "发件省/州", false, group: "sender", example: "Guangdong"); + Add("sender.city", "发件城市", false, group: "sender", example: "Shenzhen"); + Add("sender.zipCode", "发件邮编", false, group: "sender", example: "518000"); + Add("sender.streetAddress1", "发件地址1", false, group: "sender", example: "Nanshan District"); + Add("sender.streetAddress2", "发件地址2", false, group: "sender"); + Add("sender.streetAddress3", "发件地址3", false, group: "sender"); + Add("sender.houseNumber", "发件门牌号", false, group: "sender", example: "88"); + + Add("items[0].cnName", "申报品名(中)", false, group: "declaration", example: "手机壳"); + Add("items[0].enName", "申报品名(英)", true, group: "declaration", example: "Phone Case"); + Add("items[0].sku", "SKU", false, group: "declaration", example: "SKU001"); + Add("items[0].unit", "单位", false, group: "declaration", example: "pcs"); + Add("items[0].unitPrice", "单价", true, "number", group: "declaration", example: "12.5"); + Add("items[0].unitWeight", "单重", true, "number", group: "declaration", example: "0.3"); + Add("items[0].quantity", "申报数量", true, "number", group: "declaration", example: "1"); + Add("items[0].amount", "总金额", false, "number", group: "declaration", example: "12.5"); + Add("items[0].weight", "总重量", false, "number", group: "declaration", example: "0.3"); + Add("items[0].imHsCode", "进口HsCode", false, group: "declaration"); + Add("items[0].exHsCode", "出口HsCode", false, group: "declaration"); + Add("items[0].skuImage", "商品图片URL", false, group: "declaration"); + Add("items[0].category", "类别", false, group: "declaration", example: "Accessories"); + Add("items[0].brand", "品牌", false, group: "declaration", example: "DemoBrand"); + Add("items[0].brandType", "品牌类型", false, group: "declaration"); + Add("items[0].isMagnetic", "是否带磁带电", false, "boolean", group: "declaration", example: "false"); + Add("items[0].distribution", "配货信息", false, group: "declaration"); + Add("items[0].material", "材质", false, group: "declaration", example: "plastic"); + Add("items[0].enMaterial", "英文材质", false, group: "declaration", example: "plastic"); + Add("items[0].purpose", "用途", false, group: "declaration", example: "protection"); + Add("items[0].attributes", "属性", false, group: "declaration"); + Add("items[0].originCountry", "原产国", false, group: "declaration", example: "CN"); + Add("items[0].salesLink", "销售链接", false, group: "declaration"); + Add("items[0].model", "型号", false, group: "declaration", example: "Model-X"); + Add("items[0].specifications", "规格", false, group: "declaration", example: "Standard"); + Add("items[0].comment", "备注", false, group: "declaration", example: "示例订单数据"); + + return list; + } +} diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs new file mode 100644 index 0000000..c5904b2 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateHeaderMatcher.cs @@ -0,0 +1,145 @@ +using System.Text.RegularExpressions; +using Oms.Application.Services.Orders.Templates; + +namespace Oms.Application.Services.Orders.Templates; + +/// +/// 客户 Excel 表头与标准字段智能匹配。 +/// +public static class OrderTemplateHeaderMatcher +{ + private static readonly Regex NoisePattern = new(@"[\s\*\(\)()\[\]【】::,,.。/\\\-_]+", RegexOptions.Compiled); + + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["客户单号"] = "clientNo", + ["客户订单号"] = "clientNo", + ["外部单号"] = "clientNo", + ["跟踪号"] = "trackNo", + ["运单号"] = "serviceNo", + ["运输方式"] = "productCode", + ["产品代码"] = "productCode", + ["产品编码"] = "productCode", + ["货物属性"] = "cargoAttribute", + ["出口报关方式"] = "exportCustomsMode", + ["仓库"] = "warehouseId", + ["仓库ID"] = "warehouseId", + ["长cm"] = "length", + ["长"] = "length", + ["宽cm"] = "width", + ["宽"] = "width", + ["高cm"] = "height", + ["高"] = "height", + ["实重kg"] = "actualWeight", + ["实重"] = "actualWeight", + ["申报币种"] = "declaredCurrency", + ["包裹数量"] = "quantity", + ["收件人名称"] = "receiver.recipientsName", + ["收件姓名"] = "receiver.recipientsName", + ["收件人手机"] = "receiver.phone", + ["收件国家代码"] = "receiver.countryCode", + ["收件国家"] = "receiver.countryCode", + ["收件地址1"] = "receiver.streetAddress1", + ["收件邮编"] = "receiver.zipCode", + ["发件人姓名"] = "sender.senderName", + ["申报品名英"] = "items[0].enName", + ["品名英"] = "items[0].enName", + ["申报品名中"] = "items[0].cnName", + ["单价"] = "items[0].unitPrice", + ["单重"] = "items[0].unitWeight", + ["申报数量"] = "items[0].quantity", + }; + + public static string? MatchStandardFieldKey(string? sourceHeader, string? templateKey) + { + var normalized = Normalize(sourceHeader); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + if (Aliases.TryGetValue(normalized, out var aliasKey)) + { + return aliasKey; + } + + var fields = OrderTemplateFieldRegistry.GetFields(templateKey); + var byLabel = fields.FirstOrDefault(f => Normalize(f.Label) == normalized); + if (byLabel != null) + { + return byLabel.FieldKey; + } + + return fields.FirstOrDefault(f => + normalized.Contains(Normalize(f.Label), StringComparison.OrdinalIgnoreCase) || + Normalize(f.Label).Contains(normalized, StringComparison.OrdinalIgnoreCase))?.FieldKey; + } + + public static OrderTemplateColumnMappingDocument BuildSuggestedMapping( + string standardTemplateKey, + IReadOnlyList sourceHeaders) + { + var fields = OrderTemplateFieldRegistry.GetFields(standardTemplateKey); + var usedColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + var mappings = new List(); + + foreach (var field in fields) + { + var matched = sourceHeaders.FirstOrDefault(h => + { + var key = MatchStandardFieldKey(h, standardTemplateKey); + return string.Equals(key, field.FieldKey, StringComparison.OrdinalIgnoreCase); + }); + if (!string.IsNullOrWhiteSpace(matched) && usedColumns.Add(matched)) + { + mappings.Add(new OrderTemplateColumnMappingItem + { + StandardFieldKey = field.FieldKey, + SourceColumn = matched, + Required = field.Required + }); + } + } + + var mappedColumns = mappings.Select(x => x.SourceColumn).ToHashSet(StringComparer.OrdinalIgnoreCase); + return new OrderTemplateColumnMappingDocument + { + StandardTemplateKey = standardTemplateKey, + HeaderRowIndex = 1, + DataStartRowIndex = 2, + Mappings = mappings, + UnmappedSourceColumns = sourceHeaders.Where(h => !mappedColumns.Contains(h)).ToList() + }; + } + + public static List ReadHeaderRow(Stream stream) + { + stream.Position = 0; + var firstRow = MiniExcelLibs.MiniExcel.Query(stream, useHeaderRow: false) + .Take(1) + .Cast>() + .FirstOrDefault(); + + if (firstRow == null) + { + return []; + } + + return firstRow.Values + .Select(v => v?.ToString()?.Trim()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string Normalize(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return NoisePattern.Replace(text.Trim(), string.Empty); + } +} diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs new file mode 100644 index 0000000..1f46143 --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateImportMapping.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using Fake.Application; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders.Templates; + +/// +/// 解析模板导入时使用的列映射配置。 +/// +public static class OrderTemplateImportMapping +{ + public static void EnsureTemplateReadyForImport(OrderTemplate template) + { + if (!string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!string.Equals(template.MappingStatus, OrderTemplateMappingStatus.Ready, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("自定义模板尚未完成字段映射,请先配置并保存映射"); + } + + if (string.IsNullOrWhiteSpace(template.ColumnMappingJson)) + { + throw new BusinessException("自定义模板映射配置为空"); + } + } + + public static OrderTemplateColumnMappingDocument Resolve(OrderTemplate template) + { + var standardKey = template.StandardTemplateKey + ?? template.TemplateKey + ?? OrderTemplateKeys.DedicatedSmallParcel; + + if (string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase)) + { + return Deserialize(template.ColumnMappingJson) + ?? throw new BusinessException("自定义模板映射配置无效"); + } + + return BuildStandard(standardKey); + } + + public static OrderTemplateColumnMappingDocument BuildStandard(string templateKey) + { + var fields = OrderTemplateFieldRegistry.GetFields(templateKey); + return new OrderTemplateColumnMappingDocument + { + StandardTemplateKey = templateKey, + HeaderRowIndex = 1, + DataStartRowIndex = 4, + Mappings = fields.Select(field => new OrderTemplateColumnMappingItem + { + StandardFieldKey = field.FieldKey, + SourceColumn = field.Label, + Required = field.Required + }).ToList() + }; + } + + public static OrderTemplateColumnMappingDocument? Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json); + } + catch + { + return null; + } + } +} diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs new file mode 100644 index 0000000..572e87c --- /dev/null +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs @@ -0,0 +1,263 @@ +using System.Globalization; +using System.Reflection; +using Oms.Application.Dtos.Orders; + +namespace Oms.Application.Services.Orders.Templates; + +/// +/// 将 Excel 源列值映射为 。 +/// +public static class OrderTemplateRowMapper +{ + private static readonly HashSet ForceStringFields = new(StringComparer.OrdinalIgnoreCase) + { + "requestId", "clientNo", "trackNo", "serviceNo", "productCode", "cargoAttribute", "exportCustomsMode", + "codCurrency", "declaredCurrency", "receiver.recipientsName", "receiver.companyName", "receiver.phone", + "receiver.telePhone", "receiver.email", "receiver.countryCode", "receiver.province", "receiver.city", + "receiver.streetNumber", "receiver.streetAddress1", "receiver.streetAddress2", "receiver.streetAddress3", + "receiver.zipCode", "receiver.taxNo", "receiver.certificateNo", "receiver.eori", "receiver.vat", + "sender.senderName", "sender.company", "sender.phone", "sender.telePhone", "sender.email", + "sender.countryCode", "sender.province", "sender.city", "sender.zipCode", "sender.streetAddress1", + "sender.streetAddress2", "sender.streetAddress3", "sender.houseNumber", "items[0].cnName", "items[0].enName", + "items[0].sku", "items[0].unit", "items[0].imHsCode", "items[0].exHsCode", "items[0].skuImage", + "items[0].category", "items[0].brand", "items[0].brandType", "items[0].distribution", "items[0].material", + "items[0].enMaterial", "items[0].purpose", "items[0].attributes", "items[0].originCountry", "items[0].salesLink", + "items[0].model", "items[0].specifications", "items[0].comment" + }; + + private static readonly HashSet ForceNumberFields = new(StringComparer.OrdinalIgnoreCase) + { + "warehouseId", "length", "width", "height", "actualWeight", "codSum", "declaredSum", "quantity", + "receiver.taxType", "receiver.certificateType", "items[0].unitPrice", "items[0].unitWeight", + "items[0].quantity", "items[0].amount", "items[0].weight" + }; + + private static readonly HashSet ForceBooleanFields = new(StringComparer.OrdinalIgnoreCase) + { + "isCod", "items[0].isMagnetic" + }; + + public static OrderParcelCreateInput MapToParcelCreateInput( + IReadOnlyDictionary sourceValues, + OrderTemplateColumnMappingDocument mapping, + int customerId) + { + var input = new OrderParcelCreateInput + { + CustomerId = customerId, + Receiver = new OrderParcelReceiverInput(), + Sender = new OrderParcelSenderInput(), + Items = [new OrderParcelItemInput()] + }; + + foreach (var item in mapping.Mappings) + { + var fieldKey = item.StandardFieldKey?.Trim(); + var sourceColumn = item.SourceColumn?.Trim(); + if (string.IsNullOrWhiteSpace(fieldKey) || string.IsNullOrWhiteSpace(sourceColumn)) + { + continue; + } + + if (!sourceValues.TryGetValue(sourceColumn, out var rawValue) || rawValue == null) + { + continue; + } + + var fieldDef = OrderTemplateFieldRegistry.TryGetField(mapping.StandardTemplateKey, fieldKey); + var coerced = CoerceValue(fieldKey, rawValue, fieldDef?.DataType); + if (coerced == null) + { + continue; + } + + SetFieldValue(input, fieldKey, coerced); + } + + return input; + } + + private static object? CoerceValue(string fieldKey, object rawValue, string? dataType) + { + if (rawValue is string text && string.IsNullOrWhiteSpace(text)) + { + return null; + } + + if (ForceStringFields.Contains(fieldKey)) + { + var stringValue = rawValue.ToString()?.Trim(); + return string.IsNullOrWhiteSpace(stringValue) ? null : stringValue; + } + + if (ForceBooleanFields.Contains(fieldKey) || string.Equals(dataType, "boolean", StringComparison.OrdinalIgnoreCase)) + { + return ParseBoolean(rawValue); + } + + if (ForceNumberFields.Contains(fieldKey) + || string.Equals(dataType, "number", StringComparison.OrdinalIgnoreCase)) + { + return ParseNumber(rawValue); + } + + return ParseAuto(rawValue); + } + + private static object? ParseAuto(object rawValue) + { + if (rawValue is bool or int or long or float or double or decimal) + { + return rawValue; + } + + var text = rawValue.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + if (bool.TryParse(text, out var boolValue)) + { + return boolValue; + } + + if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue) + || decimal.TryParse(text, NumberStyles.Any, CultureInfo.CurrentCulture, out decimalValue)) + { + return decimalValue; + } + + return text; + } + + private static bool? ParseBoolean(object rawValue) + { + if (rawValue is bool boolValue) + { + return boolValue; + } + + var text = rawValue.ToString()?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return text switch + { + "true" or "1" or "y" or "yes" or "是" => true, + "false" or "0" or "n" or "no" or "否" => false, + _ => null + }; + } + + private static object? ParseNumber(object rawValue) + { + if (rawValue is int or long or float or double or decimal) + { + return Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture); + } + + var text = rawValue.ToString()?.Trim().Replace(",", string.Empty); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue) + || decimal.TryParse(text, NumberStyles.Any, CultureInfo.CurrentCulture, out decimalValue)) + { + return decimalValue; + } + + return null; + } + + private static void SetFieldValue(OrderParcelCreateInput input, string fieldKey, object value) + { + if (fieldKey.StartsWith("receiver.", StringComparison.OrdinalIgnoreCase)) + { + input.Receiver ??= new OrderParcelReceiverInput(); + SetProperty(input.Receiver, fieldKey["receiver.".Length..], value); + return; + } + + if (fieldKey.StartsWith("sender.", StringComparison.OrdinalIgnoreCase)) + { + input.Sender ??= new OrderParcelSenderInput(); + SetProperty(input.Sender, fieldKey["sender.".Length..], value); + return; + } + + if (fieldKey.StartsWith("items[0].", StringComparison.OrdinalIgnoreCase)) + { + if (input.Items.Count == 0) + { + input.Items.Add(new OrderParcelItemInput()); + } + + SetProperty(input.Items[0], fieldKey["items[0].".Length..], value); + return; + } + + SetProperty(input, fieldKey, value); + } + + private static void SetProperty(object target, string propertyName, object value) + { + var property = target.GetType().GetProperty( + propertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (property == null || !property.CanWrite) + { + return; + } + + var converted = ConvertToType(value, property.PropertyType); + property.SetValue(target, converted); + } + + private static object? ConvertToType(object value, Type targetType) + { + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (underlyingType.IsInstanceOfType(value)) + { + return value; + } + + if (underlyingType == typeof(string)) + { + return value.ToString(); + } + + if (underlyingType == typeof(bool)) + { + return ParseBoolean(value) ?? false; + } + + if (underlyingType == typeof(int)) + { + if (value is decimal decimalValue) + { + return (int)decimalValue; + } + + return int.TryParse(value.ToString(), out var intValue) ? intValue : null; + } + + if (underlyingType == typeof(decimal)) + { + if (value is decimal decimalValue) + { + return decimalValue; + } + + return decimal.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + + return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture); + } +} diff --git a/src/Oms.Application/Shared/OmsFileUploadHelper.cs b/src/Oms.Application/Shared/OmsFileUploadHelper.cs index 1ea6de3..a086a92 100644 --- a/src/Oms.Application/Shared/OmsFileUploadHelper.cs +++ b/src/Oms.Application/Shared/OmsFileUploadHelper.cs @@ -61,6 +61,68 @@ public static class OmsFileUploadHelper return $"/uploads/recharge/{monthFolder}/{fileName}".Replace('\\', '/'); } + private static readonly HashSet ExcelExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".xlsx", ".xls" }; + + private const long MaxOrderTemplateBytes = 10 * 1024 * 1024; + + /// + /// 保存下单自定义模板样例文件。 + /// + public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync( + IFormFile file, + int customerId, + IWebHostEnvironment env) + { + if (file == null || file.Length <= 0) + { + throw new BusinessException("上传文件不能为空"); + } + + if (file.Length > MaxOrderTemplateBytes) + { + throw new BusinessException("模板文件大小不能超过 10MB"); + } + + var ext = Path.GetExtension(file.FileName); + if (string.IsNullOrWhiteSpace(ext) || !ExcelExtensions.Contains(ext)) + { + throw new BusinessException("仅支持 .xlsx / .xls 文件"); + } + + var webRoot = env.WebRootPath; + if (string.IsNullOrWhiteSpace(webRoot)) + { + webRoot = Path.Combine(env.ContentRootPath, "wwwroot"); + } + + var relativeDir = Path.Combine("uploads", "order-templates", customerId.ToString()); + var absoluteDir = Path.Combine(webRoot, relativeDir); + Directory.CreateDirectory(absoluteDir); + + var fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; + var absolutePath = Path.Combine(absoluteDir, fileName); + await using (var stream = File.Create(absolutePath)) + { + await file.CopyToAsync(stream); + } + + var relativePath = $"/uploads/order-templates/{customerId}/{fileName}".Replace('\\', '/'); + return (relativePath, Path.GetFileName(file.FileName)); + } + + public static string ResolveWebRootAbsolutePath(IWebHostEnvironment env, string relativePath) + { + var webRoot = env.WebRootPath; + if (string.IsNullOrWhiteSpace(webRoot)) + { + webRoot = Path.Combine(env.ContentRootPath, "wwwroot"); + } + + var trimmed = relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar); + return Path.Combine(webRoot, trimmed); + } + private static string? GuessExtensionFromContentType(string? contentType) => contentType?.ToLowerInvariant() switch { "image/jpeg" or "image/jpg" => ".jpg", diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderTemplate.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderTemplate.cs index 4cb67f9..8700190 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/OrderTemplate.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderTemplate.cs @@ -12,5 +12,18 @@ public class OrderTemplate : OmsAuditedAggregateRoot public string? TemplateKey { get; set; } + public string? StandardTemplateKey { get; set; } + + /// draft / ready;标准模板固定 ready。 + public string? MappingStatus { get; set; } + + public string? SourceFileName { get; set; } + + public string? SourceFilePath { get; set; } + + public string? SourceHeadersJson { get; set; } + + public string? ColumnMappingJson { get; set; } + public int? CustomerId { get; set; } }