fix(order): 批量导入回写订单 ID 并增强 Excel 表头映射匹配
导入行成功后写入 batch job 的 orderId/serviceNo;预报失败时尽量关联草稿订单。Excel 读取与行映射支持表头归一化,兼容带括号说明的列名。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fc22d9c6c6
commit
7d5bc2408f
@ -5,6 +5,11 @@ namespace Oms.Application.Dtos.Orders;
|
||||
/// </summary>
|
||||
public class OrderParcelCreateOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单主键。
|
||||
/// </summary>
|
||||
public int OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户单号。
|
||||
/// </summary>
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user