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> /// </summary>
public class OrderParcelCreateOutput public class OrderParcelCreateOutput
{ {
/// <summary>
/// 订单主键。
/// </summary>
public int OrderId { get; set; }
/// <summary> /// <summary>
/// 客户单号。 /// 客户单号。
/// </summary> /// </summary>

View File

@ -47,8 +47,14 @@ public sealed class OrderBatchActionExecuteResult
ResultJson = resultJson, ResultJson = resultJson,
}; };
public static OrderBatchActionExecuteResult BusinessFail(string reason) => public static OrderBatchActionExecuteResult BusinessFail(string reason, int? createdOrderId = null) =>
new() { Success = false, IsBusinessFailure = true, FailReason = reason }; new()
{
Success = false,
IsBusinessFailure = true,
FailReason = reason,
CreatedOrderId = createdOrderId,
};
public static OrderBatchActionExecuteResult SystemFail(string reason) => public static OrderBatchActionExecuteResult SystemFail(string reason) =>
new() { Success = false, IsBusinessFailure = false, FailReason = 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(); using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>(); var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
await orderService.ImportRowForBatchAsync(payload, message.OperatorUserId, cancellationToken); var output = await orderService.ImportRowForBatchAsync(payload, message.OperatorUserId, cancellationToken);
return OrderBatchActionExecuteResult.Ok(); return OrderBatchActionExecuteResult.Ok(
createdOrderId: output.OrderId,
serviceNo: output.ServiceNo,
clientNo: output.ClientNo,
trackNo: output.TrackNo,
labelUrl: output.LabelUrl);
} }
catch (BusinessException ex) 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) catch (Exception ex)
{ {

View File

@ -16,7 +16,7 @@ public partial class OrderService
/// <summary> /// <summary>
/// 批量 Worker 导入单行 Excel 数据。 /// 批量 Worker 导入单行 Excel 数据。
/// </summary> /// </summary>
public async Task ImportRowForBatchAsync( public async Task<OrderParcelCreateOutput> ImportRowForBatchAsync(
OrderBatchImportRowPayload payload, OrderBatchImportRowPayload payload,
int operatorUserId, int operatorUserId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@ -30,7 +30,44 @@ public partial class OrderService
?? throw new BusinessException("行数据无效"); ?? throw new BusinessException("行数据无效");
var input = OrderTemplateRowMapper.MapToParcelCreateInput(sourceValues, mapping, payload.CustomerId); 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> /// <summary>

View File

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

View File

@ -38,10 +38,11 @@ public static class OrderTemplateExcelReader
kv => kv.Value?.ToString()?.Trim() ?? string.Empty, kv => kv.Value?.ToString()?.Trim() ?? string.Empty,
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
var mappedColumns = mapping.Mappings var mappedSourceColumns = mapping.Mappings
.Select(x => x.SourceColumn) .Select(x => x.SourceColumn?.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x)) .Where(x => !string.IsNullOrWhiteSpace(x))
.ToHashSet(StringComparer.OrdinalIgnoreCase); .Select(x => x!)
.ToList();
var rows = new List<OrderTemplateExcelDataRow>(); var rows = new List<OrderTemplateExcelDataRow>();
for (var rowIndex = mapping.DataStartRowIndex - 1; rowIndex < allRows.Count; rowIndex++) for (var rowIndex = mapping.DataStartRowIndex - 1; rowIndex < allRows.Count; rowIndex++)
@ -60,7 +61,7 @@ public static class OrderTemplateExcelReader
continue; continue;
} }
if (!mappedColumns.Contains(column.Value)) if (!IsMappedSourceHeader(column.Value, mappedSourceColumns))
{ {
continue; continue;
} }
@ -73,7 +74,7 @@ public static class OrderTemplateExcelReader
sourceValues[column.Value] = cellValue; sourceValues[column.Value] = cellValue;
} }
if (!HasMappedValue(sourceValues, mappedColumns)) if (!HasMappedValue(sourceValues, mappedSourceColumns))
{ {
continue; continue;
} }
@ -100,13 +101,25 @@ public static class OrderTemplateExcelReader
?? sheetNames[0]; ?? 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( private static bool HasMappedValue(
IReadOnlyDictionary<string, object> sourceValues, 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; continue;
} }
@ -120,6 +133,32 @@ public static class OrderTemplateExcelReader
return false; 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) private static bool IsRowEmpty(IDictionary<string, object> row)
{ {
return row.Values.All(value => string.IsNullOrWhiteSpace(value?.ToString())); return row.Values.All(value => string.IsNullOrWhiteSpace(value?.ToString()));

View File

@ -133,7 +133,7 @@ public static class OrderTemplateHeaderMatcher
.ToList(); .ToList();
} }
private static string Normalize(string? text) public static string NormalizeHeader(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
@ -142,4 +142,6 @@ public static class OrderTemplateHeaderMatcher
return NoisePattern.Replace(text.Trim(), string.Empty); 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; continue;
} }
if (!sourceValues.TryGetValue(sourceColumn, out var rawValue) || rawValue == null) if (!TryGetMappedValue(sourceValues, sourceColumn, out var rawValue) || rawValue == null)
{ {
continue; continue;
} }
@ -77,6 +77,32 @@ public static class OrderTemplateRowMapper
return input; 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) private static object? CoerceValue(string fieldKey, object rawValue, string? dataType)
{ {
if (rawValue is string text && string.IsNullOrWhiteSpace(text)) if (rawValue is string text && string.IsNullOrWhiteSpace(text))