feat(order): 下单模板映射与 Excel 批量导入小包创建
新增专线小包标准模板生成、自定义列映射 API,并将 /order/upload 改为按映射解析 Excel 后走 CreateParcelCoreAsync;列表查询时自动补齐标准模板种子数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7ab6b6fb51
commit
9ab112a36e
12
sql/migrate_t_order_template_mapping.sql
Normal file
12
sql/migrate_t_order_template_mapping.sql
Normal 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` = '');
|
||||
20
sql/seed_order_template_dedicated_small_parcel.sql
Normal file
20
sql/seed_order_template_dedicated_small_parcel.sql
Normal 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
|
||||
);
|
||||
@ -38,6 +38,8 @@ public class OrderBatchImportRowPayload
|
||||
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
public int TemplateId { get; set; }
|
||||
|
||||
public string RowDataJson { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
110
src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs
Normal file
110
src/Oms.Application/Dtos/Orders/OrderTemplateMappingDtos.cs
Normal 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; } = [];
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
421
src/Oms.Application/Services/Orders/OrderService.Template.cs
Normal file
421
src/Oms.Application/Services/Orders/OrderService.Template.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user