Improve draft predeclare billing and structured create-fail payload.

Re-run forecast billing on draft pre-declare, and expose draftSaved/orderId/serviceNo via ErrorCode/Data/details for clearer place-order failure UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-28 19:30:27 +08:00
parent 03f11a276f
commit 41f4c58e99
8 changed files with 430 additions and 40 deletions

28
db.sql
View File

@ -179,22 +179,22 @@ CREATE TABLE `t_order` (
) COMMENT='订单表';
CREATE TABLE `t_order_file` (
`Id` INT NOT NULL AUTO_INCREMENT,
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`OrderId` INT NOT NULL COMMENT '订单Id',
`FileType` VARCHAR(32) NOT NULL COMMENT 'tradeProof/systemLabel/downstreamLabel/lastMileLabel/infraredImage',
`FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
`FileName` VARCHAR(255) NULL COMMENT '原始文件名',
`FileUrl` VARCHAR(512) NOT NULL COMMENT '相对路径或外链',
`ContentType` VARCHAR(128) NULL,
`FileSize` BIGINT NULL,
`Sort` INT NOT NULL DEFAULT 0,
`Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT 'upload/systemGenerate/downstream/migrate',
`Remark` VARCHAR(512) NULL,
`IsDeleted` TINYINT NOT NULL DEFAULT 0,
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()),
`CreateUserId` INT NOT NULL DEFAULT 0,
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`UpdateUserId` INT NULL,
`UpdateTime` DATETIME NULL,
`FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
`ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
`FileSize` BIGINT NULL COMMENT '文件大小(字节)',
`Sort` INT NOT NULL DEFAULT 0 COMMENT '同类型内排序',
`Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT '来源:upload上传/systemGenerate系统生成/downstream下游回传/migrate迁移',
`Remark` VARCHAR(512) NULL COMMENT '备注',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_ord_file_order_type` (`OrderId`, `FileType`, `IsDeleted`)
) COMMENT='订单附件表';

View File

@ -1,28 +1,28 @@
-- Order files table + migrate LabelUrl into systemLabel, then drop legacy columns.
-- Run on MySQL. Backup first.
-- 订单附件表;将原 LabelUrl 迁移为 systemLabel 后删除订单旧面单字段。
-- 在 MySQL 执行前请先备份。
CREATE TABLE IF NOT EXISTS `t_order_file` (
`Id` INT NOT NULL AUTO_INCREMENT,
`OrderId` INT NOT NULL COMMENT 'Order Id',
`FileType` VARCHAR(32) NOT NULL COMMENT 'tradeProof/systemLabel/downstreamLabel/lastMileLabel/infraredImage',
`FileName` VARCHAR(255) NULL COMMENT 'Original file name',
`FileUrl` VARCHAR(512) NOT NULL COMMENT 'Relative or absolute URL',
`ContentType` VARCHAR(128) NULL,
`FileSize` BIGINT NULL,
`Sort` INT NOT NULL DEFAULT 0,
`Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT 'upload/systemGenerate/downstream/migrate',
`Remark` VARCHAR(512) NULL,
`IsDeleted` TINYINT NOT NULL DEFAULT 0,
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()),
`CreateUserId` INT NOT NULL DEFAULT 0,
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`UpdateUserId` INT NULL,
`UpdateTime` DATETIME NULL,
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`OrderId` INT NOT NULL COMMENT '订单Id',
`FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
`FileName` VARCHAR(255) NULL COMMENT '原始文件名',
`FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
`ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
`FileSize` BIGINT NULL COMMENT '文件大小(字节)',
`Sort` INT NOT NULL DEFAULT 0 COMMENT '同类型内排序',
`Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT '来源:upload上传/systemGenerate系统生成/downstream下游回传/migrate迁移',
`Remark` VARCHAR(512) NULL COMMENT '备注',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateUserId` INT NULL COMMENT '更新人',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_ord_file_order_type` (`OrderId`, `FileType`, `IsDeleted`)
) COMMENT='Order attachment files';
) COMMENT='订单附件表';
-- Migrate existing system label URL (ignore if column already dropped)
-- 迁移已有系统面单地址(若 LabelUrl 列已删除可跳过本段)
INSERT INTO `t_order_file` (`OrderId`, `FileType`, `FileName`, `FileUrl`, `ContentType`, `Sort`, `Source`, `CreateUserId`, `CreateTime`)
SELECT
`Id`,

View File

@ -0,0 +1,20 @@
-- 若 t_order_file 已按英文注释建表,执行本脚本改为中文注释(不改结构)。
ALTER TABLE `t_order_file`
MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
MODIFY COLUMN `OrderId` INT NOT NULL COMMENT '订单Id',
MODIFY COLUMN `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
MODIFY COLUMN `FileName` VARCHAR(255) NULL COMMENT '原始文件名',
MODIFY COLUMN `FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
MODIFY COLUMN `ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
MODIFY COLUMN `FileSize` BIGINT NULL COMMENT '文件大小(字节)',
MODIFY COLUMN `Sort` INT NOT NULL DEFAULT 0 COMMENT '同类型内排序',
MODIFY COLUMN `Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT '来源:upload上传/systemGenerate系统生成/downstream下游回传/migrate迁移',
MODIFY COLUMN `Remark` VARCHAR(512) NULL COMMENT '备注',
MODIFY COLUMN `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人',
MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间',
COMMENT='订单附件表';

View File

@ -0,0 +1,35 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 小包创建/草稿预报失败且已落库时的结构化错误载荷(写入异常 Data / 响应 Details)。
/// </summary>
public class OrderParcelDraftFailData
{
public const string ErrorCodeValue = "OMS_DRAFT_SAVED";
public const string DataKey = "omsDraftFail";
/// <summary>
/// 是否已保存为草稿。
/// </summary>
public bool DraftSaved { get; set; } = true;
/// <summary>
/// 订单主键。
/// </summary>
public int OrderId { get; set; }
/// <summary>
/// 运单号。
/// </summary>
public string ServiceNo { get; set; } = string.Empty;
/// <summary>
/// 干净失败原因(不含机器标记)。
/// </summary>
public string Reason { get; set; } = string.Empty;
/// <summary>
/// 稳定错误码,供前端识别。
/// </summary>
public string ErrorCode { get; set; } = ErrorCodeValue;
}

View File

@ -181,16 +181,86 @@ public partial class OrderService
}
catch (BusinessException ex)
{
await MarkParcelDraftFailedAsync(order, ex.Message);
throw;
var cleanReason = StripDraftSavedMarker(ex.Message);
await MarkParcelDraftFailedAsync(order, cleanReason);
throw CreateDraftSavedBusinessException(order, cleanReason);
}
catch (Exception ex)
{
await MarkParcelDraftFailedAsync(order, ex.Message);
throw new BusinessException(ex.Message);
var cleanReason = StripDraftSavedMarker(ex.Message);
await MarkParcelDraftFailedAsync(order, cleanReason);
throw CreateDraftSavedBusinessException(order, cleanReason);
}
}
/// <summary>
/// 草稿已落库失败时,在业务文案末尾附加机器可读标记,供前端区分「未落库 / 已存草稿」。
/// 格式:原因 [[omsDraft:{"draftSaved":true,"orderId":1,"serviceNo":"IDE..."}]]
/// 同时写入 Exception.Data / ErrorCode,由 OmsException2ResponseConverter 输出到响应 Details。
/// </summary>
private const string DraftSavedMarkerPrefix = " [[omsDraft:";
private const string DraftSavedMarkerSuffix = "]]";
private static string StripDraftSavedMarker(string? message)
{
if (string.IsNullOrEmpty(message))
{
return string.Empty;
}
var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
return idx < 0 ? message : message[..idx].TrimEnd();
}
private static string WrapDraftSavedFail(Order order, string reason)
{
var clean = StripDraftSavedMarker(reason);
if (string.IsNullOrWhiteSpace(clean))
{
clean = "预报未完成";
}
var payload = global::System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object>
{
["draftSaved"] = true,
["orderId"] = order.Id,
["serviceNo"] = order.ServiceNo ?? string.Empty,
["reason"] = clean,
["errorCode"] = OrderParcelDraftFailData.ErrorCodeValue,
});
return $"{clean}{DraftSavedMarkerPrefix}{payload}{DraftSavedMarkerSuffix}";
}
/// <summary>
/// 抛出「草稿已落库」业务异常:ErrorCode + Data 结构化载荷,msg 仍带兼容标记。
/// </summary>
private static BusinessException CreateDraftSavedBusinessException(Order order, string reason)
{
var clean = StripDraftSavedMarker(reason);
if (string.IsNullOrWhiteSpace(clean))
{
clean = "预报未完成";
}
var failData = new OrderParcelDraftFailData
{
DraftSaved = true,
OrderId = order.Id,
ServiceNo = order.ServiceNo ?? string.Empty,
Reason = clean,
ErrorCode = OrderParcelDraftFailData.ErrorCodeValue,
};
var ex = new BusinessException(WrapDraftSavedFail(order, clean));
ex.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
ex.Data["draftSaved"] = true;
ex.Data["orderId"] = order.Id;
ex.Data["serviceNo"] = order.ServiceNo ?? string.Empty;
ex.Data[OrderParcelDraftFailData.DataKey] =
global::System.Text.Json.JsonSerializer.Serialize(failData);
return ex;
}
private const string ParcelServiceNoPrefix = "IDE";
/// <summary>
@ -1536,6 +1606,106 @@ public partial class OrderService
throw new BusinessException("运单号生成失败");
}
/// <summary>
/// 草稿重新预报:按订单当前收件/件重尺/快照重跑黑名单、计重、分区与预报应收。
/// 失败时保持草稿并写入 PredeclareFailReason。
/// </summary>
internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null)
{
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法预报");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null || product.IsActive != EnableStatus.Enabled)
{
throw new BusinessException("销售产品不存在或未启用");
}
try
{
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var address = await ResolveOrderBillingAddressAsync(order);
if (await IsBlacklistHitAsync(
product,
snapshotBinding.Rules,
address.CountryCode,
address.Province,
address.City,
address.ZipCode))
{
throw new BusinessException("收件地址命中黑名单区域");
}
order.IsRemote = await IsRemoteHitAsync(
product,
snapshotBinding.Rules,
address.CountryCode,
address.Province,
address.City,
address.ZipCode) ? (byte)1 : (byte)0;
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var forecast = weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase))
?? throw new BusinessException("订单缺少预报件重尺");
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var billing = await ResolveOrderBillingContextAsync(
order,
product,
forecast.ChargeableWeight ?? 0,
snapshotBinding);
await UpsertReceivableFeesAsync(
order,
billing,
OrderFeeChargingStageCodes.Forecast,
replaceExistingReceivable: true);
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.AllocationPendingReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null, appendInitialTrack: true);
await EnqueueGenerateLabelPdfAsync(order);
try
{
var triggerUserId = operatorUserId ?? CurrentUser.GetId<int>();
await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId);
}
catch
{
// 自动触发失败不影响预报主流程
}
}
catch (BusinessException ex)
{
var cleanReason = StripDraftSavedMarker(ex.Message);
await MarkParcelDraftFailedAsync(order, cleanReason);
throw CreateDraftSavedBusinessException(order, cleanReason);
}
catch (Exception ex)
{
var cleanReason = StripDraftSavedMarker(ex.Message);
await MarkParcelDraftFailedAsync(order, cleanReason);
throw CreateDraftSavedBusinessException(order, cleanReason);
}
}
private async Task MarkParcelDraftFailedAsync(Order order, string reason)
{
order.OrderStatus = OrderStatusCodes.Draft;

View File

@ -193,8 +193,18 @@ public partial class OrderService : ApplicationService
public async Task PreDeclareAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
order.OrderStatus = OrderStatusCodes.Predeclared;
await UpdateOrderAsync(order);
if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!string.Equals(order.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(
$"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许预报");
}
await CompleteDraftPredeclareAsync(order);
}
/// <summary>

View File

@ -0,0 +1,141 @@
using System.Collections;
using Fake.Application;
using Fake.Application.Dtos;
using Fake.AspNetCore.ExceptionHandling;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Oms.Application.Dtos.Orders;
namespace SimpleAdmin.Api.ExceptionHandling;
/// <summary>
/// 在 Fake 默认异常转换之上,把草稿已落库失败的结构化信息写入 Details,并清理 msg 中的机器标记。
/// </summary>
public class OmsException2ResponseConverter : IException2ResponseConverter
{
private static readonly JsonSerializerSettings JsonSettings = new()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore,
};
private const string DraftSavedMarkerPrefix = " [[omsDraft:";
private readonly DefaultException2ResponseConverter _inner;
public OmsException2ResponseConverter(DefaultException2ResponseConverter inner)
{
_inner = inner;
}
public ApplicationExceptionResponse Convert(Exception exception, FakeExceptionHandlingOptions options)
{
var response = _inner.Convert(exception, options);
response.Message = StripDraftSavedMarker(response.Message);
if (TryBuildDraftFailData(exception, out var failData))
{
if (string.IsNullOrWhiteSpace(response.Message))
{
response.Message = failData.Reason;
}
response.Details = JsonConvert.SerializeObject(failData, JsonSettings);
}
return response;
}
private static bool TryBuildDraftFailData(Exception exception, out OrderParcelDraftFailData failData)
{
failData = new OrderParcelDraftFailData();
if (exception is not BusinessException business)
{
return false;
}
if (business.Data[OrderParcelDraftFailData.DataKey] is string json
&& !string.IsNullOrWhiteSpace(json))
{
try
{
var parsed = JsonConvert.DeserializeObject<OrderParcelDraftFailData>(json);
if (parsed != null && parsed.DraftSaved && parsed.OrderId > 0)
{
failData = parsed;
return true;
}
}
catch
{
// fall through to key-based parse
}
}
if (!HasDraftSavedFlag(business.Data))
{
// 兼容旧客户端:仅从 message 尾部标记解析
return TryParseMarker(business.Message, out failData);
}
failData.DraftSaved = true;
failData.OrderId = ToInt(business.Data["orderId"] ?? business.Data["OrderId"]);
failData.ServiceNo = ToStringValue(business.Data["serviceNo"] ?? business.Data["ServiceNo"]);
failData.Reason = StripDraftSavedMarker(business.Message);
failData.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
return failData.OrderId > 0;
}
private static bool HasDraftSavedFlag(IDictionary data)
{
if (data.Contains("draftSaved") && IsTruthy(data["draftSaved"])) return true;
if (data.Contains("DraftSaved") && IsTruthy(data["DraftSaved"])) return true;
return false;
}
private static bool TryParseMarker(string? message, out OrderParcelDraftFailData failData)
{
failData = new OrderParcelDraftFailData();
if (string.IsNullOrEmpty(message)) return false;
var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
if (idx < 0) return false;
var jsonStart = idx + DraftSavedMarkerPrefix.Length;
var jsonEnd = message.LastIndexOf("]]", StringComparison.Ordinal);
if (jsonEnd <= jsonStart) return false;
try
{
var parsed = JsonConvert.DeserializeObject<OrderParcelDraftFailData>(message[jsonStart..jsonEnd]);
if (parsed == null || !parsed.DraftSaved || parsed.OrderId <= 0) return false;
parsed.Reason = message[..idx].TrimEnd();
parsed.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
failData = parsed;
return true;
}
catch
{
return false;
}
}
private static string StripDraftSavedMarker(string? message)
{
if (string.IsNullOrEmpty(message)) return string.Empty;
var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
return idx < 0 ? message : message[..idx].TrimEnd();
}
private static bool IsTruthy(object? value) =>
value is true
|| (value is bool b && b)
|| string.Equals(global::System.Convert.ToString(value), "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(global::System.Convert.ToString(value), "1", StringComparison.OrdinalIgnoreCase);
private static int ToInt(object? value)
{
if (value == null) return 0;
return int.TryParse(global::System.Convert.ToString(value), out var n) ? n : 0;
}
private static string ToStringValue(object? value) =>
global::System.Convert.ToString(value)?.Trim() ?? string.Empty;
}

View File

@ -101,6 +101,20 @@ public class SimpleAdminApiModule : FakeModule
});
services.AddFeiShuExceptionSubscriber();
// 草稿已落库失败:结构化 Details + 清理 msg 机器标记
services.AddTransient<DefaultException2ResponseConverter>();
var exceptionConverterDescriptors = services
.Where(d => d.ServiceType == typeof(IException2ResponseConverter))
.ToList();
foreach (var descriptor in exceptionConverterDescriptors)
{
services.Remove(descriptor);
}
services.AddTransient<IException2ResponseConverter>(sp =>
new ExceptionHandling.OmsException2ResponseConverter(
sp.GetRequiredService<DefaultException2ResponseConverter>()));
}
public override void ConfigureApplication(ApplicationConfigureContext context)