Fix downstream forecast success detection and persist provider service no.

Treat itfx body code/msg as business result, store full request/response, and write Data.serviceNo/trackNo back to the order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-28 10:21:23 +08:00
parent fffecb5964
commit 6f436f795f
18 changed files with 570 additions and 16 deletions

1
db.sql
View File

@ -104,6 +104,7 @@ CREATE TABLE `t_order` (
`ServiceNo` VARCHAR(64) NOT NULL COMMENT '运单号',
`ClientNo` VARCHAR(64) NULL COMMENT '客户单号',
`TrackNo` VARCHAR(64) NULL COMMENT '跟踪号',
`DownstreamServiceNo` VARCHAR(64) NULL COMMENT '服务商单号(下游 createOrderOne Data.serviceNo)',
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode',

View File

@ -0,0 +1,5 @@
-- Add DownstreamServiceNo to t_order (provider service no from createOrderOne Data.serviceNo)
-- Backup production DB before running.
ALTER TABLE `t_order`
ADD COLUMN `DownstreamServiceNo` VARCHAR(64) NULL COMMENT 'Downstream provider service no' AFTER `TrackNo`;

View File

@ -32,4 +32,14 @@ public class OrderDownstreamForwardResult
public int? StatusCode { get; set; }
public string ResponseBody { get; set; } = string.Empty;
/// <summary>
/// 回写后的服务商单号(Data.serviceNo)。
/// </summary>
public string? DownstreamServiceNo { get; set; }
/// <summary>
/// 回写后的跟踪号(Data.trackNo)。
/// </summary>
public string? TrackNo { get; set; }
}

View File

@ -20,6 +20,11 @@ public class OrderOutput
/// </summary>
public string? TrackNo { get; set; }
/// <summary>
/// 服务商单号(下游 createOrderOne Data.serviceNo)。
/// </summary>
public string? DownstreamServiceNo { get; set; }
/// <summary>
/// 服务单号。
/// </summary>

View File

@ -25,6 +25,11 @@ public class OrderPaginatedOutput
/// </summary>
public string? TrackNo { get; set; }
/// <summary>
/// 服务商单号(下游 createOrderOne Data.serviceNo)。
/// </summary>
public string? DownstreamServiceNo { get; set; }
/// <summary>
/// 产品编码。
/// </summary>

View File

@ -0,0 +1,145 @@
using System.Text.Json;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Integrations.Itfx;
/// <summary>
/// 解析 itfx createOrderOne 成功响应中的 Data 字段,并回写订单。
/// </summary>
internal static class ItfxCreateOrderOneDataParser
{
public sealed class CreateOrderOneData
{
public string? ServiceNo { get; init; }
public string? TrackNo { get; init; }
public string? ClientNo { get; init; }
public string? LabelUrl { get; init; }
}
public static bool TryParse(string? responseBody, out CreateOrderOneData? data)
{
data = null;
if (string.IsNullOrWhiteSpace(responseBody))
{
return false;
}
try
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!TryGetPropertyIgnoreCase(root, out var dataElement, "data", "Data")
|| dataElement.ValueKind != JsonValueKind.Object)
{
return false;
}
data = new CreateOrderOneData
{
ServiceNo = ReadString(dataElement, "serviceNo", "ServiceNo"),
TrackNo = ReadString(dataElement, "trackNo", "TrackNo"),
ClientNo = ReadString(dataElement, "clientNo", "ClientNo"),
LabelUrl = ReadString(dataElement, "labelUrl", "LabelUrl"),
};
return !string.IsNullOrWhiteSpace(data.ServiceNo)
|| !string.IsNullOrWhiteSpace(data.TrackNo)
|| !string.IsNullOrWhiteSpace(data.ClientNo)
|| !string.IsNullOrWhiteSpace(data.LabelUrl);
}
catch
{
return false;
}
}
/// <summary>
/// 将下游返回写入订单:服务商单号 ← Data.serviceNo,跟踪号 ← Data.trackNo。
/// 空值不覆盖已有字段。
/// </summary>
public static bool ApplyToOrder(Order order, CreateOrderOneData data)
{
var changed = false;
if (!string.IsNullOrWhiteSpace(data.ServiceNo))
{
var value = data.ServiceNo.Trim();
if (!string.Equals(order.DownstreamServiceNo, value, StringComparison.Ordinal))
{
order.DownstreamServiceNo = value;
changed = true;
}
}
if (!string.IsNullOrWhiteSpace(data.TrackNo))
{
var value = data.TrackNo.Trim();
if (!string.Equals(order.TrackNo, value, StringComparison.Ordinal))
{
order.TrackNo = value;
changed = true;
}
}
if (!string.IsNullOrWhiteSpace(data.LabelUrl))
{
var value = data.LabelUrl.Trim();
if (!string.Equals(order.LabelUrl, value, StringComparison.Ordinal))
{
order.LabelUrl = value;
changed = true;
}
}
return changed;
}
private static bool TryGetPropertyIgnoreCase(JsonElement root, out JsonElement value, params string[] names)
{
foreach (var name in names)
{
if (root.TryGetProperty(name, out value))
{
return true;
}
}
foreach (var prop in root.EnumerateObject())
{
foreach (var name in names)
{
if (prop.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
private static string? ReadString(JsonElement root, params string[] names)
{
if (!TryGetPropertyIgnoreCase(root, out var prop, names))
{
return null;
}
return prop.ValueKind switch
{
JsonValueKind.String => prop.GetString()?.Trim(),
JsonValueKind.Number => prop.GetRawText(),
JsonValueKind.Null => null,
_ => null
};
}
}

View File

@ -51,12 +51,19 @@ internal sealed class ItfxForwardClient(
using var response = await client.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
var statusCode = (int)response.StatusCode;
var success = ItfxForwardResultEvaluator.IsBusinessSuccess(
statusCode,
responseBody,
out _,
out _);
return new ItfxForwardResult
{
Success = response.IsSuccessStatusCode,
Success = success,
Url = url,
RequestJson = requestJson,
StatusCode = (int)response.StatusCode,
StatusCode = statusCode,
ResponseBody = responseBody
};
}

View File

@ -0,0 +1,115 @@
using System.Text.Json;
namespace Oms.Application.Integrations.Itfx;
/// <summary>
/// itfx ForwardWithSubscribe 业务成败判定。
/// 注意:业务失败时常仍返回 HTTP 200,body.code=400。
/// </summary>
internal static class ItfxForwardResultEvaluator
{
public static bool IsBusinessSuccess(int? httpStatusCode, string? responseBody, out string? businessCode, out string? businessMessage)
{
businessCode = null;
businessMessage = null;
var httpOk = httpStatusCode is >= 200 and < 300;
if (TryReadBusinessFields(responseBody, out businessCode, out businessMessage))
{
if (!IsSuccessCode(businessCode))
{
return false;
}
// body.code 成功时仍要求 HTTP 2xx(若有状态码)
return httpOk || httpStatusCode is null;
}
return httpOk;
}
public static bool IsSuccessCode(string? code)
{
if (string.IsNullOrWhiteSpace(code))
{
return true;
}
var normalized = code.Trim();
return normalized is "0" or "200"
|| normalized.Equals("OK", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("Success", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("true", StringComparison.OrdinalIgnoreCase);
}
public static string BuildFailReason(int? httpStatusCode, string? responseBody, string? businessCode, string? businessMessage)
{
if (!string.IsNullOrWhiteSpace(businessMessage))
{
var codePart = string.IsNullOrWhiteSpace(businessCode) ? "" : $"code={businessCode} ";
return $"下游预报失败: {codePart}{businessMessage.Trim()}";
}
if (!string.IsNullOrWhiteSpace(responseBody))
{
var preview = responseBody.Length > 500 ? responseBody[..500] + "…" : responseBody;
return $"下游预报失败: HTTP {httpStatusCode} {preview}";
}
return $"下游预报失败: HTTP {httpStatusCode}";
}
private static bool TryReadBusinessFields(string? responseBody, out string? code, out string? message)
{
code = null;
message = null;
if (string.IsNullOrWhiteSpace(responseBody))
{
return false;
}
try
{
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
code = ReadString(root, "code", "Code");
message = ReadString(root, "msg", "Msg", "message", "Message");
return !string.IsNullOrWhiteSpace(code) || !string.IsNullOrWhiteSpace(message);
}
catch
{
return false;
}
}
private static string? ReadString(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var prop))
{
continue;
}
var value = prop.ValueKind switch
{
JsonValueKind.String => prop.GetString(),
JsonValueKind.Number => prop.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
}

View File

@ -44,15 +44,6 @@ internal sealed class SmallBagTrackOrderClient(
}
logger.LogInformation("UpsertSmallBag succeeded for order {OrderNo}", request.OrderNo);
if (request.AppendInitialTrackOnSuccess)
{
await AppendLifecycleTrackAsync(
request.OrderNo,
SmallBagTrackLifecycleEvents.CreateTrackCode,
request.Authorization,
cancellationToken);
}
}
public Task AppendLifecycleTrackAsync(
@ -105,7 +96,7 @@ internal sealed class SmallBagTrackOrderClient(
body,
JsonOptions);
return new SmallBagTrackUpsertResult
var result = new SmallBagTrackUpsertResult
{
Skipped = false,
Success = response.Response.IsSuccessStatusCode,
@ -115,6 +106,18 @@ internal sealed class SmallBagTrackOrderClient(
StatusCode = (int)response.Response.StatusCode,
ResponseBody = response.ResponseBody
};
// 创单成功后按需追加默认轨迹(A0101);批量/调试路径也走这里,避免只建订单不建轨迹。
if (result.Success && request.AppendInitialTrackOnSuccess)
{
await AppendLifecycleTrackAsync(
request.OrderNo,
SmallBagTrackLifecycleEvents.CreateTrackCode,
authorization,
cancellationToken);
}
return result;
}
private async Task AppendLifecycleTrackCoreAsync(

View File

@ -81,6 +81,8 @@ public sealed class OrderBatchActionExecutor(
}
request = await smallBagTrackUpsertRequestBuilder.BuildAsync(order, cancellationToken: cancellationToken);
// 手动「创建轨迹」无 payload:创单成功后补默认轨迹节点 A0101
request.AppendInitialTrackOnSuccess = true;
}
if (string.IsNullOrWhiteSpace(request.Authorization)
@ -187,13 +189,50 @@ public sealed class OrderBatchActionExecutor(
body,
cancellationToken);
ItfxForwardResultEvaluator.IsBusinessSuccess(
forward.StatusCode,
forward.ResponseBody,
out var businessCode,
out var businessMessage);
if (!forward.Success)
{
var reason = ItfxForwardResultEvaluator.BuildFailReason(
forward.StatusCode,
forward.ResponseBody,
businessCode,
businessMessage);
return OrderBatchActionExecuteResult.BusinessFail(
$"下游预报失败: HTTP {forward.StatusCode} {forward.ResponseBody}");
reason,
resultJson: OrderBatchDownstreamForwardResultBuilder.BuildFailure(
forward,
message.OrderId,
order.ServiceNo,
creds.SupplierCode,
reason,
businessCode,
businessMessage));
}
return OrderBatchActionExecuteResult.Ok();
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null
&& ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
{
await orderRepository.UpdateAsync(order, cancellationToken);
}
return OrderBatchActionExecuteResult.Ok(
serviceNo: order.ServiceNo,
trackNo: order.TrackNo,
resultJson: OrderBatchDownstreamForwardResultBuilder.BuildSuccess(
forward,
message.OrderId,
order.ServiceNo,
creds.SupplierCode,
businessCode,
businessMessage,
order.DownstreamServiceNo,
order.TrackNo));
}
finally
{

View File

@ -0,0 +1,87 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oms.Application.Integrations.Itfx;
using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Services.Orders.Batch;
internal static class OrderBatchDownstreamForwardResultBuilder
{
public static string BuildSuccess(
ItfxForwardResult forward,
int orderId,
string? serviceNo,
string supplierCode,
string? businessCode = null,
string? businessMessage = null,
string? downstreamServiceNo = null,
string? trackNo = null) =>
JsonConvert.SerializeObject(new
{
success = true,
action = OrderBatchActionCodes.DownstreamForwardCreateOne,
message = "下游预报成功",
orderId = orderId > 0 ? orderId : (int?)null,
serviceNo,
downstreamServiceNo,
trackNo,
downstreamForward = MapForward(forward, supplierCode, businessCode, businessMessage, downstreamServiceNo, trackNo),
});
public static string BuildFailure(
ItfxForwardResult forward,
int orderId,
string? serviceNo,
string supplierCode,
string reason,
string? businessCode = null,
string? businessMessage = null) =>
JsonConvert.SerializeObject(new
{
success = false,
action = OrderBatchActionCodes.DownstreamForwardCreateOne,
message = reason,
orderId = orderId > 0 ? orderId : (int?)null,
serviceNo,
downstreamForward = MapForward(forward, supplierCode, businessCode, businessMessage),
});
private static object MapForward(
ItfxForwardResult forward,
string supplierCode,
string? businessCode,
string? businessMessage,
string? downstreamServiceNo = null,
string? trackNo = null) =>
new
{
supplierCode,
requestUrl = forward.Url,
requestBody = TryParseJson(forward.RequestJson),
requestBodyRaw = TryParseJson(forward.RequestJson) == null ? forward.RequestJson : null,
responseStatusCode = forward.StatusCode,
responseBody = TryParseJson(forward.ResponseBody),
responseBodyRaw = TryParseJson(forward.ResponseBody) == null ? forward.ResponseBody : null,
businessCode,
businessMessage,
downstreamServiceNo,
trackNo,
};
private static object? TryParseJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JToken.Parse(json);
}
catch
{
return null;
}
}
}

View File

@ -51,6 +51,14 @@ public partial class OrderService
creds.AppSecret,
body);
if (forward.Success
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null
&& ItfxCreateOrderOneDataParser.ApplyToOrder(order, data))
{
await OrderRepository.UpdateAsync(order);
}
return new OrderDownstreamForwardResult
{
Success = forward.Success,
@ -58,7 +66,9 @@ public partial class OrderService
Url = forward.Url,
RequestJson = forward.RequestJson,
StatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody
ResponseBody = forward.ResponseBody,
DownstreamServiceNo = order.DownstreamServiceNo,
TrackNo = order.TrackNo,
};
}

View File

@ -114,6 +114,7 @@ public partial class OrderService
Id = order.Id,
ClientNo = order.ClientNo,
TrackNo = order.TrackNo,
DownstreamServiceNo = order.DownstreamServiceNo,
ServiceNo = order.ServiceNo,
ProductCode = order.ProductCode,
ProductName = !string.IsNullOrWhiteSpace(order.ProductCode)
@ -228,6 +229,7 @@ public partial class OrderService
x.Id,
x.ClientNo,
x.TrackNo,
x.DownstreamServiceNo,
x.ServiceNo,
x.ProductCode,
x.ProductName,

View File

@ -1828,6 +1828,19 @@ public partial class OrderService
}
var request = await BuildSmallBagTrackUpsertRequestAsync(order);
request.AppendInitialTrackOnSuccess = true;
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{
request.Authorization = authorization;
}
return await SmallBagTrackOrderClient.UpsertDebugAsync(request);
}

View File

@ -14,6 +14,12 @@ public class Order : OmsAuditedAggregateRoot
public string? TrackNo { get; set; }
/// <summary>
/// 服务商单号(下游 createOrderOne 返回 Data.serviceNo)。
/// </summary>
[SugarColumn(Length = 64, IsNullable = true)]
public string? DownstreamServiceNo { get; set; }
public string? ProductCode { get; set; }
public string? ChannelCode { get; set; }

View File

@ -58,7 +58,8 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
query = query.Where(x =>
x.ServiceNo.Contains(keyword)
|| (x.ClientNo ?? string.Empty).Contains(keyword)
|| (x.TrackNo ?? string.Empty).Contains(keyword));
|| (x.TrackNo ?? string.Empty).Contains(keyword)
|| (x.DownstreamServiceNo ?? string.Empty).Contains(keyword));
}
if (filter.CreateTimeStart.HasValue)
@ -137,6 +138,12 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
return order;
}
order = await FirstOrDefaultAsync(x => x.DownstreamServiceNo == normalized, cancellationToken);
if (order != null)
{
return order;
}
return await FirstOrDefaultAsync(x => x.ClientNo == normalized, cancellationToken);
}

View File

@ -0,0 +1,69 @@
using Oms.Application.Integrations.Itfx;
using Oms.Domain.OrdersAggregate.Entities;
using Xunit;
namespace Oms.Tests;
public class ItfxCreateOrderOneDataParserTests
{
[Fact]
public void TryParse_ReadsPascalCaseData()
{
const string body = """
{
"Code": "200",
"Msg": "成功",
"Data": {
"trackNo": null,
"serviceNo": "1ZK6B4420326915547",
"clientNo": "TEST25091705",
"labelUrl": ""
}
}
""";
Assert.True(ItfxCreateOrderOneDataParser.TryParse(body, out var data));
Assert.NotNull(data);
Assert.Equal("1ZK6B4420326915547", data!.ServiceNo);
Assert.True(string.IsNullOrWhiteSpace(data.TrackNo));
Assert.Equal("TEST25091705", data.ClientNo);
}
[Fact]
public void ApplyToOrder_WritesDownstreamServiceNoAndTrackNo()
{
var order = new Order
{
ServiceNo = "OMS001",
TrackNo = null,
DownstreamServiceNo = null,
};
var data = new ItfxCreateOrderOneDataParser.CreateOrderOneData
{
ServiceNo = "1ZK6B4420326915547",
TrackNo = "TRACK123",
LabelUrl = "https://label.example/a.pdf",
};
Assert.True(ItfxCreateOrderOneDataParser.ApplyToOrder(order, data));
Assert.Equal("OMS001", order.ServiceNo);
Assert.Equal("1ZK6B4420326915547", order.DownstreamServiceNo);
Assert.Equal("TRACK123", order.TrackNo);
Assert.Equal("https://label.example/a.pdf", order.LabelUrl);
}
[Fact]
public void ApplyToOrder_DoesNotClearExistingTrackNoWhenNull()
{
var order = new Order { TrackNo = "OLD", DownstreamServiceNo = null };
var data = new ItfxCreateOrderOneDataParser.CreateOrderOneData
{
ServiceNo = "NEW-SVC",
TrackNo = null,
};
Assert.True(ItfxCreateOrderOneDataParser.ApplyToOrder(order, data));
Assert.Equal("NEW-SVC", order.DownstreamServiceNo);
Assert.Equal("OLD", order.TrackNo);
}
}

View File

@ -0,0 +1,25 @@
using Oms.Application.Integrations.Itfx;
using Xunit;
namespace Oms.Tests;
public class ItfxForwardResultEvaluatorTests
{
[Theory]
[InlineData(200, """{"code":"200","msg":"成功"}""", true)]
[InlineData(200, """{"code":"0","msg":"ok"}""", true)]
[InlineData(200, """{"code":"400","msg":"未通过状态码验证。"}""", false)]
[InlineData(200, """{"Code":400,"Msg":"服务商报错"}""", false)]
[InlineData(500, """{"code":"200","msg":"成功"}""", false)] // HTTP 非 2xx 仍失败
[InlineData(500, "plain error", false)]
[InlineData(200, "", true)]
public void IsBusinessSuccess_UsesBodyCodeOverHttpStatus(int httpStatus, string body, bool expected)
{
var ok = ItfxForwardResultEvaluator.IsBusinessSuccess(httpStatus, body, out var code, out var msg);
Assert.Equal(expected, ok);
if (body.Contains("400") || body.Contains("未通过") || body.Contains("服务商"))
{
Assert.False(string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(msg));
}
}
}