fix(order): 批量导入回写订单 ID 并增强 Excel 表头映射匹配

导入行成功后写入 batch job 的 orderId/serviceNo;预报失败时尽量关联草稿订单。Excel 读取与行映射支持表头归一化,兼容带括号说明的列名。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-08 18:34:59 +08:00
parent fc22d9c6c6
commit 7d5bc2408f
8 changed files with 141 additions and 17 deletions

View File

@ -5,6 +5,11 @@ namespace Oms.Application.Dtos.Orders;
/// </summary>
public class OrderParcelCreateOutput
{
/// <summary>
/// 订单主键。
/// </summary>
public int OrderId { get; set; }
/// <summary>
/// 客户单号。
/// </summary>

View File

@ -47,8 +47,14 @@ public sealed class OrderBatchActionExecuteResult
ResultJson = resultJson,
};
public static OrderBatchActionExecuteResult BusinessFail(string reason) =>
new() { Success = false, IsBusinessFailure = true, FailReason = reason };
public static OrderBatchActionExecuteResult BusinessFail(string reason, int? createdOrderId = null) =>
new()
{
Success = false,
IsBusinessFailure = true,
FailReason = reason,
CreatedOrderId = createdOrderId,
};
public static OrderBatchActionExecuteResult SystemFail(string reason) =>
new() { Success = false, IsBusinessFailure = false, FailReason = reason };

View File

@ -32,12 +32,20 @@ public sealed class OrderBatchCreateActionHandler(IServiceScopeFactory scopeFact
{
using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
await orderService.ImportRowForBatchAsync(payload, message.OperatorUserId, cancellationToken);
return OrderBatchActionExecuteResult.Ok();
var output = await orderService.ImportRowForBatchAsync(payload, message.OperatorUserId, cancellationToken);
return OrderBatchActionExecuteResult.Ok(
createdOrderId: output.OrderId,
serviceNo: output.ServiceNo,
clientNo: output.ClientNo,
trackNo: output.TrackNo,
labelUrl: output.LabelUrl);
}
catch (BusinessException ex)
{
return OrderBatchActionExecuteResult.BusinessFail(ex.Message);
using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
var createdOrderId = await orderService.TryResolveImportOrderIdAsync(payload, cancellationToken);
return OrderBatchActionExecuteResult.BusinessFail(ex.Message, createdOrderId);
}
catch (Exception ex)
{

View File

@ -16,7 +16,7 @@ public partial class OrderService
/// <summary>
/// 批量 Worker 导入单行 Excel 数据。
/// </summary>
public async Task ImportRowForBatchAsync(
public async Task<OrderParcelCreateOutput> ImportRowForBatchAsync(
OrderBatchImportRowPayload payload,
int operatorUserId,
CancellationToken cancellationToken = default)
@ -30,7 +30,44 @@ public partial class OrderService
?? throw new BusinessException("行数据无效");
var input = OrderTemplateRowMapper.MapToParcelCreateInput(sourceValues, mapping, payload.CustomerId);
await CreateParcelForBatchAsync(input, operatorUserId, cancellationToken);
return await CreateParcelForBatchAsync(input, operatorUserId, cancellationToken);
}
public async Task<int?> TryResolveImportOrderIdAsync(
OrderBatchImportRowPayload payload,
CancellationToken cancellationToken = default)
{
try
{
var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == payload.TemplateId, cancellationToken);
if (template == null)
{
return null;
}
var mapping = OrderTemplateImportMapping.Resolve(template);
var sourceValues = JsonConvert.DeserializeObject<Dictionary<string, object>>(payload.RowDataJson);
if (sourceValues == null || sourceValues.Count == 0)
{
return null;
}
var input = OrderTemplateRowMapper.MapToParcelCreateInput(sourceValues, mapping, payload.CustomerId);
if (string.IsNullOrWhiteSpace(input.ClientNo))
{
return null;
}
var order = await OrderRepository.GetByCustomerAndClientNoAsync(
payload.CustomerId,
input.ClientNo.Trim(),
cancellationToken);
return order?.Id;
}
catch
{
return null;
}
}
/// <summary>

View File

@ -1438,6 +1438,7 @@ public partial class OrderService
var mapped = (await MapOrderOutputsAsync([order])).First();
return new OrderParcelCreateOutput
{
OrderId = order.Id,
ClientNo = order.ClientNo,
ServiceNo = order.ServiceNo,
TrackNo = order.TrackNo,

View File

@ -38,10 +38,11 @@ public static class OrderTemplateExcelReader
kv => kv.Value?.ToString()?.Trim() ?? string.Empty,
StringComparer.OrdinalIgnoreCase);
var mappedColumns = mapping.Mappings
.Select(x => x.SourceColumn)
var mappedSourceColumns = mapping.Mappings
.Select(x => x.SourceColumn?.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
.Select(x => x!)
.ToList();
var rows = new List<OrderTemplateExcelDataRow>();
for (var rowIndex = mapping.DataStartRowIndex - 1; rowIndex < allRows.Count; rowIndex++)
@ -60,7 +61,7 @@ public static class OrderTemplateExcelReader
continue;
}
if (!mappedColumns.Contains(column.Value))
if (!IsMappedSourceHeader(column.Value, mappedSourceColumns))
{
continue;
}
@ -73,7 +74,7 @@ public static class OrderTemplateExcelReader
sourceValues[column.Value] = cellValue;
}
if (!HasMappedValue(sourceValues, mappedColumns))
if (!HasMappedValue(sourceValues, mappedSourceColumns))
{
continue;
}
@ -100,13 +101,25 @@ public static class OrderTemplateExcelReader
?? sheetNames[0];
}
private static bool IsMappedSourceHeader(string headerText, IReadOnlyList<string> mappedSourceColumns)
{
if (mappedSourceColumns.Contains(headerText, StringComparer.OrdinalIgnoreCase))
{
return true;
}
var normalizedHeader = OrderTemplateHeaderMatcher.NormalizeHeader(headerText);
return mappedSourceColumns.Any(column =>
string.Equals(OrderTemplateHeaderMatcher.NormalizeHeader(column), normalizedHeader, StringComparison.OrdinalIgnoreCase));
}
private static bool HasMappedValue(
IReadOnlyDictionary<string, object> sourceValues,
IReadOnlySet<string> mappedColumns)
IReadOnlyList<string> mappedSourceColumns)
{
foreach (var column in mappedColumns)
foreach (var column in mappedSourceColumns)
{
if (!sourceValues.TryGetValue(column, out var value) || value == null)
if (!TryGetMappedValue(sourceValues, column, out var value) || value == null)
{
continue;
}
@ -120,6 +133,32 @@ public static class OrderTemplateExcelReader
return false;
}
private static bool TryGetMappedValue(
IReadOnlyDictionary<string, object> sourceValues,
string sourceColumn,
out object? value)
{
if (sourceValues.TryGetValue(sourceColumn, out value) && value != null)
{
return true;
}
var normalizedColumn = OrderTemplateHeaderMatcher.NormalizeHeader(sourceColumn);
foreach (var item in sourceValues)
{
if (!string.Equals(OrderTemplateHeaderMatcher.NormalizeHeader(item.Key), normalizedColumn, StringComparison.OrdinalIgnoreCase))
{
continue;
}
value = item.Value;
return value != null;
}
value = null;
return false;
}
private static bool IsRowEmpty(IDictionary<string, object> row)
{
return row.Values.All(value => string.IsNullOrWhiteSpace(value?.ToString()));

View File

@ -133,7 +133,7 @@ public static class OrderTemplateHeaderMatcher
.ToList();
}
private static string Normalize(string? text)
public static string NormalizeHeader(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
@ -142,4 +142,6 @@ public static class OrderTemplateHeaderMatcher
return NoisePattern.Replace(text.Trim(), string.Empty);
}
private static string Normalize(string? text) => NormalizeHeader(text);
}

View File

@ -59,7 +59,7 @@ public static class OrderTemplateRowMapper
continue;
}
if (!sourceValues.TryGetValue(sourceColumn, out var rawValue) || rawValue == null)
if (!TryGetMappedValue(sourceValues, sourceColumn, out var rawValue) || rawValue == null)
{
continue;
}
@ -77,6 +77,32 @@ public static class OrderTemplateRowMapper
return input;
}
private static bool TryGetMappedValue(
IReadOnlyDictionary<string, object> sourceValues,
string sourceColumn,
out object? rawValue)
{
if (sourceValues.TryGetValue(sourceColumn, out rawValue) && rawValue != null)
{
return true;
}
var normalizedColumn = OrderTemplateHeaderMatcher.NormalizeHeader(sourceColumn);
foreach (var item in sourceValues)
{
if (!string.Equals(OrderTemplateHeaderMatcher.NormalizeHeader(item.Key), normalizedColumn, StringComparison.OrdinalIgnoreCase))
{
continue;
}
rawValue = item.Value;
return rawValue != null;
}
rawValue = null;
return false;
}
private static object? CoerceValue(string fieldKey, object rawValue, string? dataType)
{
if (rawValue is string text && string.IsNullOrWhiteSpace(text))