1.02 #3

Merged
zzh merged 6 commits from test into master 2026-10-09 18:24:30 +08:00
12 changed files with 394 additions and 24 deletions
Showing only changes of commit da27fa4377 - Show all commits

1
db.sql
View File

@ -118,6 +118,7 @@ CREATE TABLE `t_order` (
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode',
`DownstreamSupplierName` VARCHAR(128) NULL COMMENT '下游 API 服务商名称',
`DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey',
`DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id',
`ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode',

View File

@ -0,0 +1,105 @@
-- =============================================================================
-- migrate_20261009_order_warehouse.sql
-- 汇总:
-- 1) t_order.DownstreamSupplierName(下游服务商名称)
-- 2) t_order_operation_log(运单操作日志)
-- 3) t_warehouse_address.Sort / Remark(仓库地址排序与备注)
-- 可重复执行。
-- =============================================================================
SET @db := DATABASE();
-- -----------------------------------------------------------------------------
-- 1) 订单:下游服务商名称
-- -----------------------------------------------------------------------------
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order'
AND COLUMN_NAME = 'DownstreamSupplierName'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`',
'SELECT ''t_order.DownstreamSupplierName already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- -----------------------------------------------------------------------------
-- 2) 运单操作日志表
-- -----------------------------------------------------------------------------
SET @tbl := (
SELECT COUNT(1)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order_operation_log'
);
SET @sql := IF(
@tbl = 0,
'CREATE TABLE `t_order_operation_log` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
`OrderId` INT NOT NULL COMMENT ''运单Id'',
`ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'',
`Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'',
`ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'',
`OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'',
`OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'',
`OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'',
`Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'',
`ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'',
`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_ool_order_time` (`OrderId`, `CreateTime`),
KEY `idx_ool_service_no` (`ServiceNo`)
) COMMENT=''运单操作日志''',
'SELECT ''t_order_operation_log already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- -----------------------------------------------------------------------------
-- 3) 仓库地址:展示排序 + 备注
-- -----------------------------------------------------------------------------
SET @col := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_warehouse_address'
AND COLUMN_NAME = 'Sort'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_warehouse_address`
ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`',
'SELECT ''t_warehouse_address.Sort already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @col := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_warehouse_address'
AND COLUMN_NAME = 'Remark'
);
SET @sql := IF(
@col = 0,
'ALTER TABLE `t_warehouse_address`
ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`',
'SELECT ''t_warehouse_address.Remark already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,22 @@
-- 订单增加下游服务商名称(排货确认时写入 itfx 服务商名 / 分组名)。
-- 可重复执行。
SET @db := DATABASE();
SET @exists := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db
AND TABLE_NAME = 't_order'
AND COLUMN_NAME = 'DownstreamSupplierName'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`',
'SELECT ''t_order.DownstreamSupplierName already exists'' AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 切换订单绑定的销售产品规则/价格发布版本。
/// </summary>
public class OrderBindSnapshotVersionsInput
{
[Required]
public int Id { get; set; }
/// <summary>
/// 目标规则版本;空表示不改。特殊值 <c>CURRENT</c> 表示绑定当前有效规则发布。
/// </summary>
[StringLength(64)]
public string? RulesVersion { get; set; }
/// <summary>
/// 目标价格版本;空表示不改。特殊值 <c>CURRENT</c> 表示绑定当前有效价格发布。
/// </summary>
[StringLength(64)]
public string? PriceVersion { get; set; }
/// <summary>
/// 绑定后是否立即重算应收(已支付会拒绝)。
/// </summary>
public bool RecalculateReceivable { get; set; }
/// <summary>
/// 绑定后是否立即重算应付(锁定当前渠道)。
/// </summary>
public bool RecalculatePayable { get; set; }
}

View File

@ -55,6 +55,11 @@ public class OrderOutput
/// </summary>
public string? DownstreamSupplierCode { get; set; }
/// <summary>
/// 下游 API 服务商名称。
/// </summary>
public string? DownstreamSupplierName { get; set; }
/// <summary>
/// 下游 itfx 应用 AppKey。
/// </summary>
@ -363,6 +368,16 @@ public class OrderOutput
/// </summary>
public decimal? ForecastPayableAmount { get; set; }
/// <summary>
/// 订单绑定的销售产品规则发布版本。
/// </summary>
public string? SalesProductRulesVersion { get; set; }
/// <summary>
/// 订单绑定的销售产品价格发布版本。
/// </summary>
public string? SalesProductPriceVersion { get; set; }
/// <summary>
/// 创建时间。
/// </summary>

View File

@ -28,6 +28,8 @@ public static class OrderOperationLogSnapshot
["payableAmount"] = "应付金额",
["downstreamServiceNo"] = "服务商单号",
["allocationScheme"] = "排货方案",
["salesProductRulesVersion"] = "规则版本",
["salesProductPriceVersion"] = "价格版本",
["isPaid"] = "是否已支付",
["sender.name"] = "发件人姓名",
["sender.phone"] = "发件人电话",
@ -70,6 +72,8 @@ public static class OrderOperationLogSnapshot
["payableAmount"] = FormatDecimal(order.PayableAmount),
["downstreamServiceNo"] = Norm(order.DownstreamServiceNo),
["allocationScheme"] = Norm(order.AllocationScheme),
["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion),
["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion),
["isPaid"] = FormatByte(order.IsPaid),
};
}

View File

@ -165,6 +165,7 @@ public partial class OrderService
MaxTimeliness = product?.MaxTimeliness,
ChannelCode = order.ChannelCode,
DownstreamSupplierCode = order.DownstreamSupplierCode,
DownstreamSupplierName = order.DownstreamSupplierName,
DownstreamAppKey = order.DownstreamAppKey,
DownstreamAppId = order.DownstreamAppId,
ChannelForecastCode = order.ChannelForecastCode,
@ -262,6 +263,12 @@ public partial class OrderService
HasInsurance = hasInsurance,
ForecastReceivableAmount = order.ForecastReceivableAmount,
ForecastPayableAmount = order.ForecastPayableAmount,
SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
? null
: order.SalesProductRulesVersion.Trim(),
SalesProductPriceVersion = string.IsNullOrWhiteSpace(order.SalesProductPriceVersion)
? null
: order.SalesProductPriceVersion.Trim(),
CreateTime = order.CreateTime,
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap),
UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime),
@ -387,6 +394,7 @@ public partial class OrderService
output.ChannelCode = null;
output.ChannelName = null;
output.DownstreamSupplierCode = null;
output.DownstreamSupplierName = null;
output.DownstreamAppKey = null;
output.DownstreamAppId = null;
output.ChannelForecastCode = null;

View File

@ -286,6 +286,127 @@ public partial class OrderService
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 切换订单绑定的销售产品规则/价格发布版本(显式改契约后再重算)。
/// </summary>
[HttpPost("/order/fee/bind-snapshot-versions")]
public async Task<OrderOutput> BindSnapshotVersionsAsync([FromBody] OrderBindSnapshotVersionsInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法切换版本");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("销售产品不存在");
}
var wantRules = !string.IsNullOrWhiteSpace(input.RulesVersion);
var wantPrice = !string.IsNullOrWhiteSpace(input.PriceVersion);
if (!wantRules && !wantPrice)
{
throw new BusinessException("请至少指定规则版本或价格版本");
}
var remarkParts = new List<string>();
if (wantRules)
{
var resolved = await ResolveBindTargetVersionAsync(
product,
RulesOperatorType,
input.RulesVersion!.Trim(),
"规则");
if (!string.Equals(order.SalesProductRulesVersion, resolved, StringComparison.Ordinal))
{
remarkParts.Add($"rules:{order.SalesProductRulesVersion ?? "-"}→{resolved}");
order.SalesProductRulesVersion = resolved;
}
}
if (wantPrice)
{
var resolved = await ResolveBindTargetVersionAsync(
product,
PriceOperatorType,
input.PriceVersion!.Trim(),
"价格");
if (!string.Equals(order.SalesProductPriceVersion, resolved, StringComparison.Ordinal))
{
remarkParts.Add($"price:{order.SalesProductPriceVersion ?? "-"}→{resolved}");
order.SalesProductPriceVersion = resolved;
}
}
if (remarkParts.Count == 0 && !input.RecalculateReceivable && !input.RecalculatePayable)
{
throw new BusinessException("目标版本与当前绑定相同,且未勾选重算");
}
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
if (input.RecalculateReceivable)
{
EnsureReceivableMutable(order);
await RecalculateReceivableCoreAsync(order);
}
if (input.RecalculatePayable)
{
await RecalculatePayableCoreAsync(order);
}
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.BindSnapshotVersions,
beforeSnap,
afterSnap,
remarkParts.Count == 0 ? null : string.Join(';', remarkParts));
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 解析绑定目标版本:CURRENT 取当前有效发布,否则按版本号校验存在。
/// </summary>
private async Task<string> ResolveBindTargetVersionAsync(
SalesProduct product,
string operatorType,
string versionOrCurrent,
string label)
{
if (string.Equals(versionOrCurrent, "CURRENT", StringComparison.OrdinalIgnoreCase))
{
var effective = await SalesProductRepository.GetEffectivePublishRecordAsync(
product.Id,
operatorType,
DateTime.Now);
if (effective == null || string.IsNullOrWhiteSpace(effective.Version))
{
throw new BusinessException($"未命中当前有效{label}发布版本");
}
return effective.Version.Trim();
}
var record = await SalesProductRepository.GetPublishRecordByVersionAsync(
product.Id,
operatorType,
versionOrCurrent);
if (record == null || string.IsNullOrWhiteSpace(record.Version))
{
throw new BusinessException($"未找到{label}发布版本:{versionOrCurrent}");
}
return record.Version.Trim();
}
/// <summary>
/// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。
/// </summary>
@ -1010,6 +1131,7 @@ public partial class OrderService
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
// 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。
// 锁渠道重算应付:允许渠道不在旧规则排货池内(渠道已确认,只重算买价)。
var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order);
@ -1032,7 +1154,14 @@ public partial class OrderService
billing,
chargingStage,
schemeOverride,
forceChannelId);
forceChannelId,
allowDesignatedOutsidePool: lockCurrentChannel);
// 重新排货成功后写回规则版本,使后续重算应付与当前排货池一致;卖价版本保持契约,不随排货改写。
if (enforceReallocateGate)
{
order.SalesProductRulesVersion = snapshotBinding.RulesVersion;
}
if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
{

View File

@ -848,7 +848,8 @@ public partial class OrderService
string chargingStage,
string? _,
string? schemeOverride = null,
int? forceServiceChannelId = null)
int? forceServiceChannelId = null,
bool allowDesignatedOutsidePool = false)
{
var evaluation = await EvaluateAllocationAsync(
order,
@ -858,7 +859,8 @@ public partial class OrderService
chargeableWeight,
chargingStage,
schemeOverride,
forceServiceChannelId);
forceServiceChannelId,
allowDesignatedOutsidePool);
ApplyAllocationTraceToOrder(order, evaluation);
@ -928,7 +930,8 @@ public partial class OrderService
decimal chargeableWeight,
string chargingStage,
string? schemeOverride,
int? forceServiceChannelId)
int? forceServiceChannelId,
bool allowDesignatedOutsidePool = false)
{
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
@ -985,27 +988,61 @@ public partial class OrderService
if (designated.Count == 0)
{
var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value);
var failReason = poolRow == null
? "指定渠道不在该销售产品排货池"
: "指定渠道不满足判断条件";
if (poolRow != null
&& skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
// 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。
if (allowDesignatedOutsidePool)
{
skipped.Add(new OrderAllocationSkippedOutput
var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
x.Id == forceServiceChannelId.Value);
if (lockedChannel == null)
{
ServiceChannelId = forceServiceChannelId.Value,
ChannelCode = poolRow.ChannelCode,
ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
Stage = AllocationResolver.SkipStageCondition,
MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
Reason = failReason
});
return BaseEval("订单服务渠道不存在,无法重算应付");
}
var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto
{
SalesProductId = product.Id,
ServiceChannelId = lockedChannel.Id,
ServiceChannelName = lockedChannel.ChannelName,
ProviderName = lockedChannel.ChannelName ?? lockedChannel.ChannelCode,
ChannelCode = lockedChannel.ChannelCode,
Weight = 0,
CustomerIds = [],
MatchCondition = null
};
if (poolRow == null
|| AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment(lockedProvider))
{
await AllocationProviderSnapshotEnricher.EnrichAsync([lockedProvider]);
}
filtered = [lockedProvider];
}
else
{
var failReason = poolRow == null
? "指定渠道不在该销售产品排货池"
: "指定渠道不满足判断条件";
if (poolRow != null
&& skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
{
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = forceServiceChannelId.Value,
ChannelCode = poolRow.ChannelCode,
ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
Stage = AllocationResolver.SkipStageCondition,
MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
Reason = failReason
});
}
return BaseEval(failReason);
return BaseEval(failReason);
}
}
else
{
filtered = designated;
}
filtered = designated;
}
if (filtered.Count == 0)
@ -1304,7 +1341,8 @@ public partial class OrderService
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
int? forceServiceChannelId = null) =>
int? forceServiceChannelId = null,
bool allowDesignatedOutsidePool = false) =>
ConfirmServiceChannelAndPayableAsync(
order,
product,
@ -1314,7 +1352,8 @@ public partial class OrderService
chargingStage,
billing.PriceTierKey,
schemeOverride,
forceServiceChannelId);
forceServiceChannelId,
allowDesignatedOutsidePool);
/// <summary>
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
@ -1325,7 +1364,8 @@ public partial class OrderService
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
int? forceServiceChannelId = null)
int? forceServiceChannelId = null,
bool allowDesignatedOutsidePool = false)
{
try
{
@ -1335,7 +1375,8 @@ public partial class OrderService
billing,
chargingStage,
schemeOverride,
forceServiceChannelId);
forceServiceChannelId,
allowDesignatedOutsidePool);
return true;
}
catch (BusinessException ex)

View File

@ -108,6 +108,9 @@ public class AllocationProviderSnapshotEnricher(
ServiceChannel? channelFallback = null)
{
order.DownstreamSupplierCode = NullIfEmpty(provider.SupplierCode);
order.DownstreamSupplierName = NullIfEmpty(provider.ApiServiceProviderName)
?? NullIfEmpty(provider.GroupName)
?? NullIfEmpty(provider.ProviderName);
order.DownstreamAppKey = NullIfEmpty(provider.AppKey);
order.DownstreamAppId = provider.AppId;
order.ChannelForecastCode = NullIfEmpty(provider.ForecastCode)

View File

@ -7,6 +7,7 @@ public static class OrderOperationActionCodes
public const string RecalcReceivable = "recalcReceivable";
public const string RecalcPayable = "recalcPayable";
public const string Reallocate = "reallocate";
public const string BindSnapshotVersions = "bindSnapshotVersions";
public static string GetActionName(string action) => action switch
{
@ -15,6 +16,7 @@ public static class OrderOperationActionCodes
RecalcReceivable => "重算应收",
RecalcPayable => "重算应付",
Reallocate => "重新排货",
BindSnapshotVersions => "切换价格/规则版本",
_ => action
};
}

View File

@ -29,6 +29,12 @@ public class Order : OmsAuditedAggregateRoot
/// </summary>
public string? DownstreamSupplierCode { get; set; }
/// <summary>
/// 下游 API 服务商名称(itfx service provider name)。
/// </summary>
[SugarColumn(Length = 128, IsNullable = true)]
public string? DownstreamSupplierName { get; set; }
/// <summary>
/// 下游 itfx 应用 AppKey。
/// </summary>