feat(order): 下单模板映射与 Excel 批量导入小包创建

新增专线小包标准模板生成、自定义列映射 API,并将 /order/upload 改为按映射解析 Excel 后走 CreateParcelCoreAsync;列表查询时自动补齐标准模板种子数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-08 16:42:22 +08:00
parent 7ab6b6fb51
commit 9ab112a36e
20 changed files with 1662 additions and 111 deletions

View File

@ -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` = '');

View File

@ -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
);

View File

@ -38,6 +38,8 @@ public class OrderBatchImportRowPayload
public int CustomerId { get; set; }
public int TemplateId { get; set; }
public string RowDataJson { get; set; } = string.Empty;
}

View File

@ -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<OrderTemplateFieldSchemaItemOutput> 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<OrderTemplateDictOptionOutput> 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<string> 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<string> SourceHeaders { get; set; } = [];
public OrderTemplateColumnMappingDocument? Mapping { get; set; }
public List<OrderTemplateFieldSchemaItemOutput> 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<OrderTemplateSaveMappingItemInput> 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<string> MissingRequiredFields { get; set; } = [];
}

View File

@ -39,4 +39,19 @@ public class OrderTemplateOutput
/// 更新时间。
/// </summary>
public DateTime? UpdateTime { get; set; }
/// <summary>
/// 映射状态:draft / ready。
/// </summary>
public string? MappingStatus { get; set; }
/// <summary>
/// 所依据的标准模板 Key。
/// </summary>
public string? StandardTemplateKey { get; set; }
/// <summary>
/// 客户样例文件名(自定义模板)。
/// </summary>
public string? SourceFileName { get; set; }
}

View File

@ -32,7 +32,7 @@ public sealed class OrderBatchCreateActionHandler(IServiceScopeFactory scopeFact
{
using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
await orderService.ImportRowForBatchAsync(payload, cancellationToken);
await orderService.ImportRowForBatchAsync(payload, message.OperatorUserId, cancellationToken);
return OrderBatchActionExecuteResult.Ok();
}
catch (BusinessException ex)

View File

@ -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
/// </summary>
public async Task ImportRowForBatchAsync(
OrderBatchImportRowPayload payload,
int operatorUserId,
CancellationToken cancellationToken = default)
{
var rowDict = JsonConvert.DeserializeObject<Dictionary<string, object>>(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<Dictionary<string, object>>(payload.RowDataJson)
?? throw new BusinessException("行数据无效");
await CreateImportOrderAsync(rowDict, payload.CustomerId);
var input = OrderTemplateRowMapper.MapToParcelCreateInput(sourceValues, mapping, payload.CustomerId);
await CreateParcelForBatchAsync(input, operatorUserId, cancellationToken);
}
/// <summary>

View File

@ -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<OmsPageResult<OrderTemplateOutput>> 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<string, object?>
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<string, object> 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<string, object> rowDict)
{
parsedRows.Add((rowNumber, new Dictionary<string, object>()));
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<OrderTaskFail>();
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<string, object> 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<string, object>()));
}
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<string, object> 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<OmsPageResult<OrderTaskOutput>> GetTaskOutputsAsync(OrderTaskListInput input)
{
var page = OmsDocHelper.NormalizePage(input.Page);

View File

@ -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
{
/// <summary>
/// 获取标准模板字段定义。
/// </summary>
[HttpGet("/order/template/field-schema")]
public Task<OrderTemplateFieldSchemaOutput> 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()
});
}
/// <summary>
/// 获取模板下拉字典选项。
/// </summary>
[HttpGet("/order/template/dict-options")]
public async Task<OrderTemplateDictOptionsOutput> 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() };
}
/// <summary>
/// 上传自定义模板样例并创建草稿映射。
/// </summary>
[HttpPost("/order/template/custom/upload")]
public async Task<OrderTemplateCustomUploadOutput> 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
};
}
/// <summary>
/// 获取自定义模板详情(含映射)。
/// </summary>
[HttpGet("/order/template/custom/detail")]
public async Task<OrderTemplateCustomDetailOutput> 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()
};
}
/// <summary>
/// 保存自定义模板字段映射。
/// </summary>
[HttpPost("/order/template/custom/save-mapping")]
public async Task<OrderTemplateSaveMappingOutput> 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<string>(StringComparer.OrdinalIgnoreCase);
var mappings = new List<OrderTemplateColumnMappingItem>();
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
};
}
/// <summary>
/// 删除自定义模板。
/// </summary>
[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);
}
/// <summary>
/// 列表查询前确保标准「专线小包模板」存在(未执行种子脚本时自动补齐)。
/// </summary>
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<OrderTemplate> 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<string> DeserializeStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static OrderTemplateColumnMappingDocument? DeserializeMapping(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<OrderTemplateColumnMappingDocument>(json);
}
catch
{
return null;
}
}
private async Task<List<OrderTemplateDictOption>> LoadTemplateDictOptionsAsync(IReadOnlyCollection<string> dictTypes)
{
var result = new List<OrderTemplateDictOption>();
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<Dictionary<string, string>> BuildTemplateExampleOverridesAsync(int? customerId)
{
var overrides = new Dictionary<string, string>(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;
}
}

View File

@ -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<IOrderTaskRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();

View File

@ -0,0 +1,37 @@
namespace Oms.Application.Services.Orders.Templates;
/// <summary>
/// 自定义模板列映射 JSON。
/// </summary>
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<OrderTemplateColumnMappingItem> Mappings { get; set; } = [];
public List<string> 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;
}

View File

@ -0,0 +1,11 @@
namespace Oms.Application.Services.Orders.Templates;
/// <summary>
/// Excel 解析后的单行源数据(列名 -> 单元格值)。
/// </summary>
public sealed class OrderTemplateExcelDataRow
{
public int RowNumber { get; init; }
public Dictionary<string, object> SourceValues { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,78 @@
using MiniExcelLibs;
namespace Oms.Application.Services.Orders.Templates;
/// <summary>
/// 按标准字段注册表生成 Excel 模板(专线小包等)。
/// </summary>
public static class OrderTemplateExcelGenerator
{
public const string DataSheetName = "下单数据";
private const string GuideSheetName = "字段说明";
private const string DictSheetName = "下拉选项";
public static async Task<byte[]> BuildStandardTemplateAsync(
string templateKey,
IReadOnlyDictionary<string, string>? exampleOverrides = null,
IReadOnlyList<OrderTemplateDictOption>? dictOptions = null)
{
var fields = OrderTemplateFieldRegistry.GetFields(templateKey);
var overrides = exampleOverrides ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var requiredRow = new Dictionary<string, object?>(StringComparer.Ordinal);
var exampleRow = 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;
}
}
var guideRows = fields.Select(f => new Dictionary<string, object?>
{
["列名"] = 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<string, object?>
{
["字典类型"] = x.DictType,
["编码"] = x.Code,
["显示名"] = x.Label
})
.ToList();
if (dictRows.Count == 0)
{
dictRows.Add(new Dictionary<string, object?>
{
["字典类型"] = string.Empty,
["编码"] = string.Empty,
["显示名"] = string.Empty
});
}
var sheets = new Dictionary<string, object>
{
[DataSheetName] = new List<Dictionary<string, object?>> { requiredRow, exampleRow },
[GuideSheetName] = guideRows,
[DictSheetName] = dictRows
};
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, sheets);
return stream.ToArray();
}
}

View File

@ -0,0 +1,149 @@
using Fake.Application;
using MiniExcelLibs;
namespace Oms.Application.Services.Orders.Templates;
/// <summary>
/// 按模板列映射读取 Excel 数据行。
/// </summary>
public static class OrderTemplateExcelReader
{
public static async Task<List<OrderTemplateExcelDataRow>> 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<IDictionary<string, object>>()
.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<OrderTemplateExcelDataRow>();
for (var rowIndex = mapping.DataStartRowIndex - 1; rowIndex < allRows.Count; rowIndex++)
{
var rawRow = allRows[rowIndex];
if (IsRowEmpty(rawRow))
{
continue;
}
var sourceValues = new Dictionary<string, object>(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<string, object> sourceValues,
IReadOnlySet<string> 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<string, object> 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;
}
}

View File

@ -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<OrderTemplateFieldDefinition> DedicatedSmallParcelFields = BuildDedicatedSmallParcelFields();
public static IReadOnlyList<OrderTemplateFieldDefinition> 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<OrderTemplateFieldDefinition> BuildDedicatedSmallParcelFields()
{
var list = new List<OrderTemplateFieldDefinition>();
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;
}
}

View File

@ -0,0 +1,145 @@
using System.Text.RegularExpressions;
using Oms.Application.Services.Orders.Templates;
namespace Oms.Application.Services.Orders.Templates;
/// <summary>
/// 客户 Excel 表头与标准字段智能匹配。
/// </summary>
public static class OrderTemplateHeaderMatcher
{
private static readonly Regex NoisePattern = new(@"[\s\*\(\)()\[\]【】::,,.。/\\\-_]+", RegexOptions.Compiled);
private static readonly Dictionary<string, string> 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<string> sourceHeaders)
{
var fields = OrderTemplateFieldRegistry.GetFields(standardTemplateKey);
var usedColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mappings = new List<OrderTemplateColumnMappingItem>();
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<string> ReadHeaderRow(Stream stream)
{
stream.Position = 0;
var firstRow = MiniExcelLibs.MiniExcel.Query(stream, useHeaderRow: false)
.Take(1)
.Cast<IDictionary<string, object>>()
.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);
}
}

View File

@ -0,0 +1,78 @@
using System.Text.Json;
using Fake.Application;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders.Templates;
/// <summary>
/// 解析模板导入时使用的列映射配置。
/// </summary>
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<OrderTemplateColumnMappingDocument>(json);
}
catch
{
return null;
}
}
}

View File

@ -0,0 +1,263 @@
using System.Globalization;
using System.Reflection;
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Services.Orders.Templates;
/// <summary>
/// 将 Excel 源列值映射为 <see cref="OrderParcelCreateInput"/>。
/// </summary>
public static class OrderTemplateRowMapper
{
private static readonly HashSet<string> 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<string> 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<string> ForceBooleanFields = new(StringComparer.OrdinalIgnoreCase)
{
"isCod", "items[0].isMagnetic"
};
public static OrderParcelCreateInput MapToParcelCreateInput(
IReadOnlyDictionary<string, object> 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);
}
}

View File

@ -61,6 +61,68 @@ public static class OmsFileUploadHelper
return $"/uploads/recharge/{monthFolder}/{fileName}".Replace('\\', '/');
}
private static readonly HashSet<string> ExcelExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".xlsx", ".xls" };
private const long MaxOrderTemplateBytes = 10 * 1024 * 1024;
/// <summary>
/// 保存下单自定义模板样例文件。
/// </summary>
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",

View File

@ -12,5 +12,18 @@ public class OrderTemplate : OmsAuditedAggregateRoot
public string? TemplateKey { get; set; }
public string? StandardTemplateKey { get; set; }
/// <summary>draft / ready;标准模板固定 ready。</summary>
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; }
}