Merge remote-tracking branch 'prod/test' into test

This commit is contained in:
xiaolipro 2026-10-10 15:42:36 +08:00
commit 6f56edc6a4
46 changed files with 2821 additions and 238 deletions

25
db.sql
View File

@ -104,6 +104,7 @@ DROP TABLE IF EXISTS `t_order_sender`;
DROP TABLE IF EXISTS `t_order_batch_job_item`; DROP TABLE IF EXISTS `t_order_batch_job_item`;
DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_batch_job`;
DROP TABLE IF EXISTS `t_order_template`; DROP TABLE IF EXISTS `t_order_template`;
DROP TABLE IF EXISTS `t_order_operation_log`;
DROP TABLE IF EXISTS `t_order_exception_event`; DROP TABLE IF EXISTS `t_order_exception_event`;
DROP TABLE IF EXISTS `t_order`; DROP TABLE IF EXISTS `t_order`;
@ -117,6 +118,7 @@ CREATE TABLE `t_order` (
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码', `ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码', `ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode', `DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode',
`DownstreamSupplierName` VARCHAR(128) NULL COMMENT '下游 API 服务商名称',
`DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey', `DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey',
`DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id', `DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id',
`ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode', `ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode',
@ -528,6 +530,29 @@ CREATE TABLE `t_order_exception_event` (
KEY `idx_oee_order_time` (`OrderId`, `CreateTime`) KEY `idx_oee_order_time` (`OrderId`, `CreateTime`)
) COMMENT='订单异常事件表'; ) COMMENT='订单异常事件表';
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='运单操作日志';
CREATE TABLE `t_sales_product` ( CREATE TABLE `t_sales_product` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码', `ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码',

View File

@ -0,0 +1,213 @@
-- =============================================================================
-- drop_all_unique_indexes.sql
-- 去掉当前库所有非 PRIMARY 唯一索引的「唯一性」(改为同名普通 INDEX)
-- 流程:备份并删除外键 → UNIQUE 改 KEY → 恢复外键
-- 解决 Error 1553:Cannot drop index ... needed in a foreign key constraint
-- 可重复执行;唯一性请在业务代码中校验。
-- 请在 Navicat / DBeaver / MySQL Workbench 中整段执行(需要 DELIMITER)。
-- =============================================================================
SET @db := DATABASE();
-- ---------- 预览:将处理的唯一索引 ----------
SELECT
TABLE_NAME,
INDEX_NAME,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db
AND NON_UNIQUE = 0
AND INDEX_NAME <> 'PRIMARY'
GROUP BY TABLE_NAME, INDEX_NAME
ORDER BY TABLE_NAME, INDEX_NAME;
-- ---------- 预览:库中物理外键(会临时删除再重建)----------
SELECT
rc.CONSTRAINT_NAME,
rc.TABLE_NAME,
rc.REFERENCED_TABLE_NAME,
rc.UPDATE_RULE,
rc.DELETE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS rc
WHERE rc.CONSTRAINT_SCHEMA = @db
ORDER BY rc.TABLE_NAME, rc.CONSTRAINT_NAME;
DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`;
DELIMITER $$
CREATE PROCEDURE `sp_drop_all_unique_indexes`()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE v_fk_name VARCHAR(128);
DECLARE v_fk_table VARCHAR(128);
DECLARE v_ref_table VARCHAR(128);
DECLARE v_update VARCHAR(16);
DECLARE v_delete VARCHAR(16);
DECLARE v_cols TEXT;
DECLARE v_ref_cols TEXT;
DECLARE v_table VARCHAR(128);
DECLARE v_index VARCHAR(128);
DECLARE v_index_cols TEXT;
DECLARE v_sql TEXT;
-- 1) 备份当前库全部外键定义
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`;
CREATE TEMPORARY TABLE `_tmp_fk_backup` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`constraint_name` VARCHAR(128) NOT NULL,
`table_name` VARCHAR(128) NOT NULL,
`column_names` TEXT NOT NULL,
`ref_table_name` VARCHAR(128) NOT NULL,
`ref_column_names` TEXT NOT NULL,
`update_rule` VARCHAR(16) NOT NULL,
`delete_rule` VARCHAR(16) NOT NULL
);
INSERT INTO `_tmp_fk_backup` (
`constraint_name`, `table_name`, `column_names`,
`ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule`
)
SELECT
kcu.CONSTRAINT_NAME,
kcu.TABLE_NAME,
GROUP_CONCAT(CONCAT('`', kcu.COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','),
kcu.REFERENCED_TABLE_NAME,
GROUP_CONCAT(CONCAT('`', kcu.REFERENCED_COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','),
rc.UPDATE_RULE,
rc.DELETE_RULE
FROM information_schema.KEY_COLUMN_USAGE kcu
INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
AND rc.TABLE_NAME = kcu.TABLE_NAME
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
GROUP BY
kcu.CONSTRAINT_NAME,
kcu.TABLE_NAME,
kcu.REFERENCED_TABLE_NAME,
rc.UPDATE_RULE,
rc.DELETE_RULE;
-- 2) 删除全部外键(解除对唯一索引的占用)
BEGIN
DECLARE cur_fk_drop CURSOR FOR
SELECT `constraint_name`, `table_name` FROM `_tmp_fk_backup` ORDER BY `id`;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
SET done = 0;
OPEN cur_fk_drop;
fk_drop_loop: LOOP
FETCH cur_fk_drop INTO v_fk_name, v_fk_table;
IF done = 1 THEN
LEAVE fk_drop_loop;
END IF;
SET v_sql = CONCAT('ALTER TABLE `', v_fk_table, '` DROP FOREIGN KEY `', v_fk_name, '`');
SET @ddl := v_sql;
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur_fk_drop;
END;
-- 3) 唯一索引 → 同名普通 INDEX(去掉唯一性,保留列与查询能力,便于外键重建)
BEGIN
DECLARE cur_uk CURSOR FOR
SELECT
s.TABLE_NAME,
s.INDEX_NAME,
GROUP_CONCAT(CONCAT('`', s.COLUMN_NAME, '`') ORDER BY s.SEQ_IN_INDEX SEPARATOR ',')
FROM information_schema.STATISTICS s
WHERE s.TABLE_SCHEMA = DATABASE()
AND s.NON_UNIQUE = 0
AND s.INDEX_NAME <> 'PRIMARY'
GROUP BY s.TABLE_NAME, s.INDEX_NAME
ORDER BY s.TABLE_NAME, s.INDEX_NAME;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
SET done = 0;
OPEN cur_uk;
uk_loop: LOOP
FETCH cur_uk INTO v_table, v_index, v_index_cols;
IF done = 1 THEN
LEAVE uk_loop;
END IF;
SET v_sql = CONCAT(
'ALTER TABLE `', v_table, '` DROP INDEX `', v_index,
'`, ADD INDEX `', v_index, '` (', v_index_cols, ')'
);
SET @ddl := v_sql;
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur_uk;
END;
-- 4) 按备份重建外键
-- 若原外键引用的是「非主键唯一键」,去掉唯一性后 InnoDB 可能拒绝重建,将跳过并写入警告表
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`;
CREATE TEMPORARY TABLE `_tmp_fk_restore_fail` (
`constraint_name` VARCHAR(128) NOT NULL,
`table_name` VARCHAR(128) NOT NULL,
`error_msg` TEXT
);
BEGIN
DECLARE cur_fk_add CURSOR FOR
SELECT
`constraint_name`, `table_name`, `column_names`,
`ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule`
FROM `_tmp_fk_backup`
ORDER BY `id`;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION
BEGIN
GET DIAGNOSTICS CONDITION 1 @fk_err = MESSAGE_TEXT;
INSERT INTO `_tmp_fk_restore_fail` (`constraint_name`, `table_name`, `error_msg`)
VALUES (v_fk_name, v_fk_table, @fk_err);
END;
SET done = 0;
OPEN cur_fk_add;
fk_add_loop: LOOP
FETCH cur_fk_add INTO
v_fk_name, v_fk_table, v_cols, v_ref_table, v_ref_cols, v_update, v_delete;
IF done = 1 THEN
LEAVE fk_add_loop;
END IF;
SET v_sql = CONCAT(
'ALTER TABLE `', v_fk_table,
'` ADD CONSTRAINT `', v_fk_name,
'` FOREIGN KEY (', v_cols,
') REFERENCES `', v_ref_table, '` (', v_ref_cols,
') ON UPDATE ', v_update,
' ON DELETE ', v_delete
);
SET @ddl := v_sql;
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur_fk_add;
END;
-- 输出重建失败的外键(通常因引用侧不再是 UNIQUE/PRIMARY)
SELECT * FROM `_tmp_fk_restore_fail`;
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`;
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`;
END$$
DELIMITER ;
CALL `sp_drop_all_unique_indexes`();
DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`;
-- ---------- 校验:唯一索引应无结果(PRIMARY 除外)----------
SELECT
TABLE_NAME,
INDEX_NAME,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db
AND NON_UNIQUE = 0
AND INDEX_NAME <> 'PRIMARY'
GROUP BY TABLE_NAME, INDEX_NAME
ORDER BY TABLE_NAME, INDEX_NAME;

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,42 @@
-- =============================================================================
-- migrate_order_operation_log.sql
-- 运单操作日志表(业务动作 + 字段级 before/after diff)
-- 可重复执行
-- =============================================================================
SET @db := DATABASE();
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/bindSnapshotVersions/updateAdditionalServices/addFee/pay/preDeclare/downstreamForward/print'',
`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;

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

@ -0,0 +1,49 @@
namespace Oms.Application.Dtos.Orders;
public class OrderOperationLogChangeItem
{
public string Path { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Before { get; set; }
public string? After { get; set; }
}
public class OrderOperationLogOutput
{
public int Id { get; set; }
public int OrderId { get; set; }
public string? ServiceNo { get; set; }
public string Action { get; set; } = string.Empty;
public string ActionName { get; set; } = string.Empty;
public int OperatorId { get; set; }
public string? OperatorName { get; set; }
public string OperatorType { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public List<OrderOperationLogChangeItem> Changes { get; set; } = [];
public string? Remark { get; set; }
/// <summary>
/// 列表「内容」列:由备注 + 字段变更摘要拼装。
/// </summary>
public string Summary { get; set; } = string.Empty;
public DateTime CreateTime { get; set; }
}
public class OrderOperationLogListOutput
{
public List<OrderOperationLogOutput> Items { get; set; } = [];
}

View File

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

View File

@ -41,12 +41,12 @@ public class OrderParcelCreateOutput
public string? ConsigneeCountryName { get; set; } public string? ConsigneeCountryName { get; set; }
/// <summary> /// <summary>
/// 同步创建的揽收单号(上门揽收成功时)。 /// 揽收单号(上门揽收;事件总线消费较快或同步回落时可能已有值)。
/// </summary> /// </summary>
public string? PickupNo { get; set; } public string? PickupNo { get; set; }
/// <summary> /// <summary>
/// 同步创建的揽收单 Id。 /// 揽收单 Id。
/// </summary> /// </summary>
public int? PickupOrderId { get; set; } public int? PickupOrderId { get; set; }
@ -59,4 +59,24 @@ public class OrderParcelCreateOutput
/// 揽收单创建失败原因(运单已成功时仅作提示)。 /// 揽收单创建失败原因(运单已成功时仅作提示)。
/// </summary> /// </summary>
public string? PickupCreateError { get; set; } public string? PickupCreateError { get; set; }
/// <summary>
/// 揽收单已入事件总线、后台创建中(此时尚无 PickupNo)。
/// </summary>
public bool PickupQueued { get; set; }
/// <summary>
/// 订单状态(创单后可能仍为草稿,预报计费异步处理中)。
/// </summary>
public string? OrderStatus { get; set; }
/// <summary>
/// 预报失败/处理中原因。
/// </summary>
public string? PredeclareFailReason { get; set; }
/// <summary>
/// 预报计费/排货已入队或后台处理中。
/// </summary>
public bool ForecastBillingQueued { get; set; }
} }

View File

@ -1,6 +1,9 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Services.Orders.Batch; using Oms.Application.Services.Orders.Batch;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Integrations.SmallBagTrack; namespace Oms.Application.Integrations.SmallBagTrack;
@ -15,31 +18,42 @@ internal sealed class SmallBagTrackOrderNotifier(
try try
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var coordinator = scope.ServiceProvider.GetRequiredService<OrderBatchJobCoordinator>(); var orderRepository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId); var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
if (!published) if (order == null)
{ {
await FallbackDirectAsync(request, orderId); return;
} }
var publisher = scope.ServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
await publisher.PublishTrackUpsertAsync(order, request, customerId);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning( logger.LogWarning(
ex, ex,
"RabbitMQ track upsert publish failed, fallback to direct call. orderId={OrderId}", "Track upsert publish failed, fallback via batch coordinator. orderId={OrderId}",
orderId); orderId);
await FallbackDirectAsync(request, orderId); await FallbackBatchOrDirectAsync(request, orderId, customerId);
} }
}); });
} }
private async Task FallbackDirectAsync(SmallBagTrackUpsertRequest request, int orderId) private async Task FallbackBatchOrDirectAsync(
SmallBagTrackUpsertRequest request,
int orderId,
int? customerId)
{ {
try try
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>(); var coordinator = scope.ServiceProvider.GetRequiredService<OrderBatchJobCoordinator>();
await client.UpsertAsync(request); var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId);
if (!published)
{
var client = scope.ServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();
await client.UpsertAsync(request);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -49,23 +63,29 @@ internal sealed class SmallBagTrackOrderNotifier(
public void EnqueueLifecycleTrackAppend(string orderNo, string trackCode, int orderId, string? authorization = null) public void EnqueueLifecycleTrackAppend(string orderNo, string trackCode, int orderId, string? authorization = null)
{ {
_ = Task.Run(async () => _ = PublishLifecycleAsync(orderNo, trackCode, orderId, authorization);
}
private async Task PublishLifecycleAsync(
string orderNo,
string trackCode,
int orderId,
string? authorization)
{
try
{ {
try using var scope = scopeFactory.CreateScope();
{ var publisher = scope.ServiceProvider.GetRequiredService<IOrderSideEffectEventPublisher>();
using var scope = scopeFactory.CreateScope(); await publisher.PublishLifecycleTrackAppendAsync(orderId, orderNo, trackCode, authorization);
var client = scope.ServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>(); }
await client.AppendLifecycleTrackAsync(orderNo, trackCode, authorization); catch (Exception ex)
} {
catch (Exception ex) logger.LogWarning(
{ ex,
logger.LogWarning( "Lifecycle track append publish failed. orderId={OrderId} orderNo={OrderNo} trackCode={TrackCode}",
ex, orderId,
"Async lifecycle track append failed. orderId={OrderId} orderNo={OrderNo} trackCode={TrackCode}", orderNo,
orderId, trackCode);
orderNo, }
trackCode);
}
});
} }
} }

View File

@ -0,0 +1,22 @@
using Oms.Application.Services.Orders.Issues;
namespace Oms.Application.Messaging.OrderSideEffects;
/// <summary>
/// 订单侧后置副作用发布(生命周期轨迹、自动触发等)。
/// </summary>
public interface IOrderSideEffectEventPublisher
{
Task PublishLifecycleTrackAppendAsync(
int orderId,
string orderNo,
string trackCode,
string? authorization = null,
CancellationToken cancellationToken = default);
Task PublishAutoTriggerFireAsync(
int orderId,
AutoTriggerTiming timing,
int operatorUserId,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,48 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders.Issues;
namespace Oms.Application.Messaging.OrderSideEffects;
public sealed class OrderAutoTriggerFireEventHandler(
AutoTriggerRuntimeService autoTriggerRuntimeService,
IUnitOfWorkManager unitOfWorkManager,
ILogger<OrderAutoTriggerFireEventHandler> logger)
: IEventHandler<OrderAutoTriggerFireIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
OrderAutoTriggerFireIntegrationEvent @event,
CancellationToken cancellationToken)
{
if (@event.OrderId <= 0
|| !Enum.TryParse<AutoTriggerTiming>(@event.Timing, ignoreCase: true, out var timing))
{
logger.LogWarning(
"AutoTrigger fire skipped invalid payload OrderId={OrderId} Timing={Timing}",
@event.OrderId,
@event.Timing);
return;
}
using var unitOfWork = unitOfWorkManager.Begin();
try
{
await autoTriggerRuntimeService.FireByOrderIdAsync(
@event.OrderId,
timing,
@event.OperatorUserId);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"AutoTrigger fire failed OrderId={OrderId} Timing={Timing}",
@event.OrderId,
timing);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -0,0 +1,19 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.OrderSideEffects;
/// <summary>
/// 订单自动触发(下单 / 入库 / 出库时机),走 Fake.EventBus.RabbitMQ。
/// </summary>
[Serializable]
public class OrderAutoTriggerFireIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
/// <summary>
/// <see cref="Services.Orders.Issues.AutoTriggerTiming"/> 枚举名。
/// </summary>
public string Timing { get; set; } = string.Empty;
public int OperatorUserId { get; set; }
}

View File

@ -0,0 +1,49 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Integrations.SmallBagTrack;
namespace Oms.Application.Messaging.OrderSideEffects;
public sealed class OrderLifecycleTrackAppendEventHandler(
ISmallBagTrackOrderClient trackClient,
IUnitOfWorkManager unitOfWorkManager,
ILogger<OrderLifecycleTrackAppendEventHandler> logger)
: IEventHandler<OrderLifecycleTrackAppendIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
OrderLifecycleTrackAppendIntegrationEvent @event,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(@event.OrderNo) || string.IsNullOrWhiteSpace(@event.TrackCode))
{
logger.LogWarning(
"Lifecycle track append skipped empty OrderNo/TrackCode OrderId={OrderId}",
@event.OrderId);
return;
}
using var unitOfWork = unitOfWorkManager.Begin();
try
{
await trackClient.AppendLifecycleTrackAsync(
@event.OrderNo,
@event.TrackCode,
@event.Authorization,
cancellationToken);
}
catch (Exception ex)
{
// 轨迹失败不反复打满队列;与原 Task.Run 吞异常策略一致
logger.LogWarning(
ex,
"Lifecycle track append failed OrderId={OrderId} OrderNo={OrderNo} TrackCode={TrackCode}",
@event.OrderId,
@event.OrderNo,
@event.TrackCode);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -0,0 +1,18 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.OrderSideEffects;
/// <summary>
/// 订单生命周期轨迹追加(如入库 A0301、出库等),走 Fake.EventBus.RabbitMQ。
/// </summary>
[Serializable]
public class OrderLifecycleTrackAppendIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
public string OrderNo { get; set; } = string.Empty;
public string TrackCode { get; set; } = string.Empty;
public string? Authorization { get; set; }
}

View File

@ -0,0 +1,112 @@
using Fake.EventBus.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders.Issues;
namespace Oms.Application.Messaging.OrderSideEffects;
/// <summary>
/// 优先 Fake.EventBus.RabbitMQ;发布失败则同进程同步执行 Handler。
/// </summary>
public sealed class OrderSideEffectEventPublisher(
IDistributedEventBus eventBus,
IServiceScopeFactory scopeFactory,
ILogger<OrderSideEffectEventPublisher> logger) : IOrderSideEffectEventPublisher
{
public async Task PublishLifecycleTrackAppendAsync(
int orderId,
string orderNo,
string trackCode,
string? authorization = null,
CancellationToken cancellationToken = default)
{
if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode))
{
return;
}
var @event = new OrderLifecycleTrackAppendIntegrationEvent
{
OrderId = orderId,
OrderNo = orderNo.Trim(),
TrackCode = trackCode.Trim(),
Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim()
};
if (await TryPublishAsync(@event, cancellationToken))
{
return;
}
await ExecuteInScopeAsync<OrderLifecycleTrackAppendEventHandler>(
h => h.HandleAsync(@event, cancellationToken),
cancellationToken);
}
public async Task PublishAutoTriggerFireAsync(
int orderId,
AutoTriggerTiming timing,
int operatorUserId,
CancellationToken cancellationToken = default)
{
if (orderId <= 0)
{
return;
}
var @event = new OrderAutoTriggerFireIntegrationEvent
{
OrderId = orderId,
Timing = timing.ToString(),
OperatorUserId = operatorUserId
};
if (await TryPublishAsync(@event, cancellationToken))
{
return;
}
await ExecuteInScopeAsync<OrderAutoTriggerFireEventHandler>(
h => h.HandleAsync(@event, cancellationToken),
cancellationToken);
}
private async Task<bool> TryPublishAsync(IntegrationEvent @event, CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(@event, cancellationToken);
logger.LogDebug(
"Published side-effect event {EventName} Id={EventId}",
@event.GetType().Name,
@event.Id);
return true;
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Publish side-effect event {EventName} failed, fallback to sync. EventId={EventId}",
@event.GetType().Name,
@event.Id);
return false;
}
}
private async Task ExecuteInScopeAsync<THandler>(
Func<THandler, Task> action,
CancellationToken cancellationToken)
where THandler : notnull
{
try
{
using var scope = scopeFactory.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<THandler>();
await action(handler);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name);
}
}
}

View File

@ -0,0 +1,46 @@
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 创单后置副作用:发布到 Fake.EventBus.RabbitMQ;发布失败时回落同步执行。
/// </summary>
public interface IOrderParcelCreateEventPublisher
{
/// <summary>
/// 轨迹订单 + 创建成功轨迹(A0101)合并为一个事件。
/// </summary>
Task PublishTrackUpsertAsync(
Order order,
SmallBagTrackUpsertRequest request,
int? customerId,
CancellationToken cancellationToken = default);
Task PublishLabelGenerateAsync(
Order order,
int operatorUserId = 0,
CancellationToken cancellationToken = default);
Task PublishCustoms9610UpsertAsync(
Order order,
string? platformCode = null,
CancellationToken cancellationToken = default);
/// <summary>
/// 发布揽收挂接事件。返回 true 表示已入队;false 表示已同步回落执行(或参数不足未执行)。
/// </summary>
Task<bool> PublishPickupAttachAsync(
OrderParcelCreateInput input,
int orderId,
CancellationToken cancellationToken = default);
/// <summary>
/// 同步执行揽收挂接(用于创单 HTTP 需要回写 PickupNo 的场景)。
/// </summary>
Task<PickupOrderOutput?> ExecutePickupAttachAsync(
OrderParcelCreateInput input,
int orderId,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,198 @@
using Fake.EventBus.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Services.Orders;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 创单后置事件发布器:优先走 Fake.EventBus.RabbitMQ,失败则同进程同步执行 Handler 逻辑。
/// </summary>
public sealed class OrderParcelCreateEventPublisher(
IDistributedEventBus eventBus,
IServiceScopeFactory scopeFactory,
ILogger<OrderParcelCreateEventPublisher> logger) : IOrderParcelCreateEventPublisher
{
public async Task PublishTrackUpsertAsync(
Order order,
SmallBagTrackUpsertRequest request,
int? customerId,
CancellationToken cancellationToken = default)
{
var @event = new OrderParcelTrackUpsertIntegrationEvent
{
OrderId = order.Id,
CustomerId = customerId,
PayloadJson = JsonConvert.SerializeObject(request),
AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
Authorization = request.Authorization
};
if (await TryPublishAsync(@event, cancellationToken))
{
return;
}
await ExecuteInScopeAsync<OrderParcelTrackUpsertEventHandler>(
h => h.HandleAsync(@event, cancellationToken),
cancellationToken);
}
public async Task PublishLabelGenerateAsync(
Order order,
int operatorUserId = 0,
CancellationToken cancellationToken = default)
{
var @event = new OrderParcelLabelGenerateIntegrationEvent
{
OrderId = order.Id,
OperatorUserId = operatorUserId
};
if (await TryPublishAsync(@event, cancellationToken))
{
return;
}
await ExecuteInScopeAsync<OrderParcelLabelGenerateEventHandler>(
h => h.HandleAsync(@event, cancellationToken),
cancellationToken);
}
public async Task PublishCustoms9610UpsertAsync(
Order order,
string? platformCode = null,
CancellationToken cancellationToken = default)
{
var @event = new OrderParcelCustoms9610UpsertIntegrationEvent
{
OrderId = order.Id,
PlatformCode = platformCode
};
if (await TryPublishAsync(@event, cancellationToken))
{
return;
}
await ExecuteInScopeAsync<OrderParcelCustoms9610UpsertEventHandler>(
h => h.HandleAsync(@event, cancellationToken),
cancellationToken);
}
public async Task<bool> PublishPickupAttachAsync(
OrderParcelCreateInput input,
int orderId,
CancellationToken cancellationToken = default)
{
var @event = BuildPickupEvent(input, orderId);
if (@event == null)
{
return false;
}
// 揽收回落由调用方 ExecutePickupAttachAsync 同步执行,避免重复建单且便于回写单号。
return await TryPublishAsync(@event, cancellationToken);
}
public async Task<PickupOrderOutput?> ExecutePickupAttachAsync(
OrderParcelCreateInput input,
int orderId,
CancellationToken cancellationToken = default)
{
if (orderId <= 0
|| input.SenderAddressId is not > 0
|| input.ExpectPickupTime is null
|| input.ExpectPickupTime == default
|| input.WarehouseId is not > 0)
{
return null;
}
using var scope = scopeFactory.CreateScope();
var pickupService = scope.ServiceProvider.GetRequiredService<PickupOrderService>();
return await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput
{
CustomerId = input.CustomerId,
SenderAddressId = input.SenderAddressId.Value,
Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection),
WarehouseId = input.WarehouseId.Value,
CarrierCode = input.CarrierCode,
CarrierService = input.CarrierService,
ExpectPickupTime = input.ExpectPickupTime.Value,
OrderIds = [orderId],
Remark = null
});
}
private static OrderParcelPickupAttachIntegrationEvent? BuildPickupEvent(
OrderParcelCreateInput input,
int orderId)
{
if (orderId <= 0
|| input.SenderAddressId is not > 0
|| input.ExpectPickupTime is null
|| input.ExpectPickupTime == default
|| input.WarehouseId is not > 0)
{
return null;
}
return new OrderParcelPickupAttachIntegrationEvent
{
OrderId = orderId,
CustomerId = input.CustomerId,
SenderAddressId = input.SenderAddressId.Value,
WarehouseId = input.WarehouseId.Value,
PickupDirection = input.PickupDirection,
CarrierCode = input.CarrierCode,
CarrierService = input.CarrierService,
ExpectPickupTime = input.ExpectPickupTime.Value
};
}
private async Task<bool> TryPublishAsync(IntegrationEvent @event, CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(@event, cancellationToken);
logger.LogDebug(
"Published parcel-create event {EventName} Id={EventId}",
@event.GetType().Name,
@event.Id);
return true;
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Publish parcel-create event {EventName} failed, fallback to sync. EventId={EventId}",
@event.GetType().Name,
@event.Id);
return false;
}
}
private async Task ExecuteInScopeAsync<THandler>(
Func<THandler, Task> action,
CancellationToken cancellationToken)
where THandler : notnull
{
try
{
using var scope = scopeFactory.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<THandler>();
await action(handler);
}
catch (Exception ex)
{
// 同步回落失败也不抛回创单主流程
logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name);
}
}
}

View File

@ -0,0 +1,44 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 处理创单后 9610 汇总申报订单写入事件。
/// </summary>
public sealed class OrderParcelCustoms9610UpsertEventHandler(
IOrderRepository orderRepository,
Customs9610OrderWriter customs9610OrderWriter,
IUnitOfWorkManager unitOfWorkManager,
ILogger<OrderParcelCustoms9610UpsertEventHandler> logger)
: IEventHandler<OrderParcelCustoms9610UpsertIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
OrderParcelCustoms9610UpsertIntegrationEvent @event,
CancellationToken cancellationToken)
{
using var unitOfWork = unitOfWorkManager.Begin();
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken);
if (order == null)
{
logger.LogWarning("9610 upsert skipped, order missing OrderId={OrderId}", @event.OrderId);
return;
}
try
{
await customs9610OrderWriter.UpsertFromOrderAsync(order, @event.PlatformCode, cancellationToken);
}
catch (Exception ex)
{
// 与原同步路径一致:9610 失败不抛到总线重试风暴
logger.LogWarning(ex, "9610 upsert failed OrderId={OrderId}", @event.OrderId);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -0,0 +1,14 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 创单后置:按 9610 汇总申报写入/刷新 t_9610_order(非 9610 时 Handler 内会按规则清理 pending)。
/// </summary>
[Serializable]
public class OrderParcelCustoms9610UpsertIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
public string? PlatformCode { get; set; }
}

View File

@ -0,0 +1,38 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Oms.Application.Services.Orders;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 处理创单后系统面单生成事件。
/// </summary>
public sealed class OrderParcelLabelGenerateEventHandler(
IServiceScopeFactory scopeFactory,
IUnitOfWorkManager unitOfWorkManager,
ILogger<OrderParcelLabelGenerateEventHandler> logger)
: IEventHandler<OrderParcelLabelGenerateIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
OrderParcelLabelGenerateIntegrationEvent @event,
CancellationToken cancellationToken)
{
using var unitOfWork = unitOfWorkManager.Begin();
using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken);
if (!result.Success)
{
// 面单失败不阻断创单主链路;记录后正常 Ack,避免反复重试打满队列
logger.LogWarning(
"Label generate failed OrderId={OrderId} Error={Error}",
@event.OrderId,
result.Error);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -0,0 +1,14 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 创单后置:生成系统面单 PDF。
/// </summary>
[Serializable]
public class OrderParcelLabelGenerateIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
public int OperatorUserId { get; set; }
}

View File

@ -0,0 +1,51 @@
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Orders;
using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 处理创单后上门揽收挂接事件。
/// </summary>
public sealed class OrderParcelPickupAttachEventHandler(
PickupOrderService pickupOrderService,
IUnitOfWorkManager unitOfWorkManager,
ILogger<OrderParcelPickupAttachEventHandler> logger)
: IEventHandler<OrderParcelPickupAttachIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
OrderParcelPickupAttachIntegrationEvent @event,
CancellationToken cancellationToken)
{
using var unitOfWork = unitOfWorkManager.Begin();
try
{
await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput
{
CustomerId = @event.CustomerId,
SenderAddressId = @event.SenderAddressId,
Direction = PickupDirectionCodes.NormalizeOrDefault(@event.PickupDirection),
WarehouseId = @event.WarehouseId,
CarrierCode = @event.CarrierCode,
CarrierService = @event.CarrierService,
ExpectPickupTime = @event.ExpectPickupTime,
OrderIds = [@event.OrderId],
Remark = null
});
}
catch (Exception ex)
{
// 与原同步路径一致:揽收失败不回滚物流单,仅记日志(列表可补建)
logger.LogWarning(
ex,
"Pickup attach failed OrderId={OrderId}",
@event.OrderId);
}
await unitOfWork.CompleteAsync(cancellationToken);
}
}

View File

@ -0,0 +1,26 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 创单后置:上门揽收时创建并挂接揽收订单(可选再预报承运)。
/// </summary>
[Serializable]
public class OrderParcelPickupAttachIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
public int? CustomerId { get; set; }
public int SenderAddressId { get; set; }
public int WarehouseId { get; set; }
public string? PickupDirection { get; set; }
public string? CarrierCode { get; set; }
public string? CarrierService { get; set; }
public DateTime ExpectPickupTime { get; set; }
}

View File

@ -0,0 +1,76 @@
using Fake.Application;
using Fake.DependencyInjection;
using Fake.EventBus;
using Fake.UnitOfWork;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 处理「轨迹订单 + 创建成功轨迹」合并事件。
/// </summary>
public sealed class OrderParcelTrackUpsertEventHandler(
IOrderRepository orderRepository,
ISmallBagTrackOrderClient trackClient,
ISmallBagTrackUpsertRequestBuilder requestBuilder,
IUnitOfWorkManager unitOfWorkManager,
ILogger<OrderParcelTrackUpsertEventHandler> logger)
: IEventHandler<OrderParcelTrackUpsertIntegrationEvent>, ITransientDependency
{
public async Task HandleAsync(
OrderParcelTrackUpsertIntegrationEvent @event,
CancellationToken cancellationToken)
{
using var unitOfWork = unitOfWorkManager.Begin();
var request = await ResolveRequestAsync(@event, cancellationToken);
request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess;
if (string.IsNullOrWhiteSpace(request.Authorization)
&& !string.IsNullOrWhiteSpace(@event.Authorization))
{
request.Authorization = @event.Authorization.Trim();
}
var result = await trackClient.UpsertDebugAsync(request, cancellationToken);
if (result.Skipped)
{
logger.LogInformation(
"Track upsert skipped OrderId={OrderId} Reason={Reason}",
@event.OrderId,
result.SkipReason);
}
else if (!result.Success)
{
throw new BusinessException(
$"轨迹订单创建失败: HTTP {result.StatusCode}");
}
await unitOfWork.CompleteAsync(cancellationToken);
}
private async Task<SmallBagTrackUpsertRequest> ResolveRequestAsync(
OrderParcelTrackUpsertIntegrationEvent @event,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(@event.PayloadJson))
{
var fromPayload = JsonConvert.DeserializeObject<SmallBagTrackUpsertRequest>(@event.PayloadJson);
if (fromPayload != null && !string.IsNullOrWhiteSpace(fromPayload.OrderNo))
{
return fromPayload;
}
}
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken)
?? throw new BusinessException("订单不存在");
if (string.IsNullOrWhiteSpace(order.ServiceNo))
{
throw new BusinessException("订单运单号为空,无法同步轨迹");
}
return await requestBuilder.BuildAsync(order, cancellationToken: cancellationToken);
}
}

View File

@ -0,0 +1,28 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.ParcelCreate;
/// <summary>
/// 创单后置:新增轨迹订单,并在成功后追加「创建订单成功」轨迹(A0101)。
/// 二者合并为同一集成事件,由 Handler 内 UpsertSmallBag + AppendInitialTrackOnSuccess 一次完成。
/// </summary>
[Serializable]
public class OrderParcelTrackUpsertIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
public int? CustomerId { get; set; }
/// <summary>
/// 序列化后的 <see cref="Integrations.SmallBagTrack.SmallBagTrackUpsertRequest"/>;
/// 为空时 Handler 按 OrderId 重建。
/// </summary>
public string? PayloadJson { get; set; }
/// <summary>
/// 是否在 Upsert 成功后追加创单默认轨迹 A0101。
/// </summary>
public bool AppendInitialTrackOnSuccess { get; set; } = true;
public string? Authorization { get; set; }
}

View File

@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Oms.Application.Messaging; using Oms.Application.Messaging;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Messaging.ParcelCreate;
namespace Oms.Application; namespace Oms.Application;
@ -11,6 +13,8 @@ public static class OrderBatchMessagingServiceExtensions
services.Configure<OrderBatchRabbitMqOptions>(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName)); services.Configure<OrderBatchRabbitMqOptions>(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName));
services.AddSingleton<IOrderBatchJobPublisher, OrderBatchJobPublisher>(); services.AddSingleton<IOrderBatchJobPublisher, OrderBatchJobPublisher>();
services.AddSingleton<IOrderBatchQueueInspector, OrderBatchQueueInspector>(); services.AddSingleton<IOrderBatchQueueInspector, OrderBatchQueueInspector>();
services.AddSingleton<IOrderParcelCreateEventPublisher, OrderParcelCreateEventPublisher>();
services.AddSingleton<IOrderSideEffectEventPublisher, OrderSideEffectEventPublisher>();
// 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管 // 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管
services.AddHostedService<OrderBatchDlxMonitorHostedService>(); services.AddHostedService<OrderBatchDlxMonitorHostedService>();
return services; return services;

View File

@ -161,18 +161,8 @@ public class OutboundRecordService : ApplicationService
var savedEntity = await OutboundRecordRepository.InsertAsync(entity); var savedEntity = await OutboundRecordRepository.InsertAsync(entity);
try // 自动触发改走 Fake.EventBus,避免拉长出库扫码 RT
{ AutoTriggerRuntimeService.EnqueueFire(order.Id, AutoTriggerTiming.Outbound, operatorId);
var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim());
if (refreshed != null)
{
await AutoTriggerRuntimeService.FireAsync(refreshed, AutoTriggerTiming.Outbound, operatorId);
}
}
catch
{
// 自动触发失败不影响出库主流程
}
return ObjectMapper.Map<OutboundRecord, OutboundRecordOutput>(savedEntity); return ObjectMapper.Map<OutboundRecord, OutboundRecordOutput>(savedEntity);
} }

View File

@ -26,6 +26,7 @@ public sealed class OrderBatchActionExecutor(
OrderDownstreamCredentialResolver downstreamCredentialResolver, OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient, IItfxForwardClient itfxForwardClient,
OrderFileManager orderFileManager, OrderFileManager orderFileManager,
OrderOperationLogWriter operationLogWriter,
OrderBatchCreateActionHandler createActionHandler, OrderBatchCreateActionHandler createActionHandler,
OrderBatchOrderActionHandler orderActionHandler) : IOrderBatchActionExecutor, IScopedDependency OrderBatchOrderActionHandler orderActionHandler) : IOrderBatchActionExecutor, IScopedDependency
{ {
@ -49,6 +50,7 @@ public sealed class OrderBatchActionExecutor(
OrderBatchActionCodes.Print => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.Print => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.GenerateLabelPdf => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.GenerateLabelPdf => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.InboundBilling => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.InboundBilling => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.ForecastBilling => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.Reallocate => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.Reallocate => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken),
@ -224,6 +226,8 @@ public sealed class OrderBatchActionExecutor(
businessMessage)); businessMessage));
} }
var beforeSnap = OrderOperationLogSnapshot.CaptureOrder(order);
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data) if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null) && data != null)
{ {
@ -242,6 +246,29 @@ public sealed class OrderBatchActionExecutor(
} }
} }
var afterSnap = OrderOperationLogSnapshot.CaptureOrder(order);
var remarkParts = new List<string> { $"供应商 {creds.SupplierCode}" };
if (!string.IsNullOrWhiteSpace(order.DownstreamServiceNo))
{
remarkParts.Add($"服务商单号 {order.DownstreamServiceNo}");
}
if (!string.IsNullOrWhiteSpace(order.TrackNo))
{
remarkParts.Add($"跟踪号 {order.TrackNo}");
}
await operationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.DownstreamForward,
beforeSnap,
afterSnap,
string.Join(";", remarkParts),
message.OperatorUserId > 0 ? message.OperatorUserId : null,
operatorName: null,
operatorType: "staff",
source: "job");
return OrderBatchActionExecuteResult.Ok( return OrderBatchActionExecuteResult.Ok(
serviceNo: order.ServiceNo, serviceNo: order.ServiceNo,
trackNo: order.TrackNo, trackNo: order.TrackNo,

View File

@ -269,6 +269,7 @@ public static class OrderBatchJobItemFeedback
OrderBatchActionCodes.Print => "打印成功", OrderBatchActionCodes.Print => "打印成功",
OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功", OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功",
OrderBatchActionCodes.InboundBilling => "入库计费/排货完成", OrderBatchActionCodes.InboundBilling => "入库计费/排货完成",
OrderBatchActionCodes.ForecastBilling => "预报计费/排货完成",
OrderBatchActionCodes.Reallocate => "重新排货成功", OrderBatchActionCodes.Reallocate => "重新排货成功",
OrderBatchActionCodes.MarkProblematic => "问题件创建成功", OrderBatchActionCodes.MarkProblematic => "问题件创建成功",
OrderBatchActionCodes.MarkClaimed => "理赔件创建成功", OrderBatchActionCodes.MarkClaimed => "理赔件创建成功",

View File

@ -67,6 +67,14 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto
await orderService.ProcessInboundBillingForBatchAsync(message.OrderId, cancellationToken); await orderService.ProcessInboundBillingForBatchAsync(message.OrderId, cancellationToken);
return OrderBatchActionExecuteResult.Ok(); return OrderBatchActionExecuteResult.Ok();
case OrderBatchActionCodes.ForecastBilling:
await orderService.ProcessForecastBillingForBatchAsync(
message.OrderId,
message.PayloadJson,
message.OperatorUserId,
cancellationToken);
return OrderBatchActionExecuteResult.Ok();
case OrderBatchActionCodes.Reallocate: case OrderBatchActionCodes.Reallocate:
await orderService.ReallocateForBatchAsync( await orderService.ReallocateForBatchAsync(
message.OrderId, message.OrderId,

View File

@ -1,6 +1,6 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories;
@ -16,13 +16,13 @@ public class AutoTriggerRuntimeService(
OrderAutoTriggerService orderAutoTriggerService, OrderAutoTriggerService orderAutoTriggerService,
OrderExceptionWorkflowService exceptionWorkflowService, OrderExceptionWorkflowService exceptionWorkflowService,
OrderIssueNotificationService issueNotificationService, OrderIssueNotificationService issueNotificationService,
IServiceScopeFactory scopeFactory, IOrderSideEffectEventPublisher sideEffectEventPublisher,
ILogger<AutoTriggerRuntimeService> logger) : IScopedDependency ILogger<AutoTriggerRuntimeService> logger) : IScopedDependency
{ {
private const string InterceptOperation = "拦截"; private const string InterceptOperation = "拦截";
/// <summary> /// <summary>
/// 后台异步触发,避免拉长入库/出库扫码请求。 /// 后台异步触发(Fake.EventBus.RabbitMQ),避免拉长入库/出库/创单请求。
/// </summary> /// </summary>
public void EnqueueFire(int orderId, AutoTriggerTiming timing, int operatorUserId) public void EnqueueFire(int orderId, AutoTriggerTiming timing, int operatorUserId)
{ {
@ -31,23 +31,7 @@ public class AutoTriggerRuntimeService(
return; return;
} }
_ = Task.Run(async () => _ = sideEffectEventPublisher.PublishAutoTriggerFireAsync(orderId, timing, operatorUserId);
{
try
{
using var scope = scopeFactory.CreateScope();
var runtime = scope.ServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
await runtime.FireByOrderIdAsync(orderId, timing, operatorUserId);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"异步自动触发失败 OrderId={OrderId} Timing={Timing}",
orderId,
timing);
}
});
} }
public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId) public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId)

View File

@ -0,0 +1,340 @@
using System.Globalization;
using Newtonsoft.Json;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 运单操作日志白名单快照与字段 label。
/// </summary>
public static class OrderOperationLogSnapshot
{
public const int SummaryMaxChangeItems = 5;
private static readonly Dictionary<string, string> Labels = new(StringComparer.OrdinalIgnoreCase)
{
["orderStatus"] = "订单状态",
["clientNo"] = "客户单号",
["trackNo"] = "跟踪号",
["channelCode"] = "服务渠道",
["productCode"] = "销售产品",
["warehouseId"] = "仓库",
["cargoAttribute"] = "货物属性",
["isCod"] = "是否COD",
["codCurrency"] = "COD币种",
["codSum"] = "COD金额",
["declaredCurrency"] = "申报币种",
["declaredSum"] = "申报金额",
["quantity"] = "包裹数量",
["receivableSubtotal"] = "应收小计",
["payableAmount"] = "应付金额",
["downstreamServiceNo"] = "服务商单号",
["allocationScheme"] = "排货方案",
["salesProductRulesVersion"] = "规则版本",
["salesProductPriceVersion"] = "价格版本",
["isPaid"] = "是否已支付",
["additionalServices"] = "附加服务",
["sender.name"] = "发件人姓名",
["sender.phone"] = "发件人电话",
["sender.country"] = "发件国家",
["sender.province"] = "发件省/州",
["sender.city"] = "发件城市",
["sender.zipCode"] = "发件邮编",
["sender.address1"] = "发件地址1",
["sender.address2"] = "发件地址2",
["receiver.name"] = "收件人姓名",
["receiver.phone"] = "收件人电话",
["receiver.country"] = "收件国家",
["receiver.province"] = "收件省/州",
["receiver.city"] = "收件城市",
["receiver.zipCode"] = "收件邮编",
["receiver.address1"] = "收件地址1",
["receiver.address2"] = "收件地址2",
["fee.receivableTotal"] = "应收合计",
["fee.payableTotal"] = "应付合计",
};
public static Dictionary<string, string?> CaptureOrder(Order order)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["orderStatus"] = Norm(order.OrderStatus),
["clientNo"] = Norm(order.ClientNo),
["trackNo"] = Norm(order.TrackNo),
["channelCode"] = Norm(order.ChannelCode),
["productCode"] = Norm(order.ProductCode),
["warehouseId"] = FormatInt(order.WarehouseId),
["cargoAttribute"] = Norm(order.CargoAttribute),
["isCod"] = FormatByte(order.IsCod),
["codCurrency"] = Norm(order.CodCurrency),
["codSum"] = FormatDecimal(order.CodSum),
["declaredCurrency"] = Norm(order.DeclaredCurrency),
["declaredSum"] = FormatDecimal(order.DeclaredSum),
["quantity"] = FormatInt(order.Quantity),
["receivableSubtotal"] = FormatDecimal(order.ReceivableSubtotal),
["payableAmount"] = FormatDecimal(order.PayableAmount),
["downstreamServiceNo"] = Norm(order.DownstreamServiceNo),
["allocationScheme"] = Norm(order.AllocationScheme),
["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion),
["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion),
["isPaid"] = FormatByte(order.IsPaid),
["additionalServices"] = FormatAdditionalServices(order.AdditionalServices),
};
}
public static Dictionary<string, string?> CaptureSender(OrderSender? sender)
{
if (sender == null)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["sender.name"] = Norm(sender.SenderName),
["sender.phone"] = Norm(sender.Phone),
["sender.country"] = FormatInt(sender.Country),
["sender.province"] = Norm(sender.Province),
["sender.city"] = Norm(sender.City),
["sender.zipCode"] = Norm(sender.ZipCode),
["sender.address1"] = Norm(sender.StreetAddress1),
["sender.address2"] = Norm(sender.StreetAddress2),
};
}
public static Dictionary<string, string?> CaptureReceiver(OrderRecipient? recipient)
{
if (recipient == null)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["receiver.name"] = Norm(recipient.RecipientsName),
["receiver.phone"] = Norm(recipient.Phone),
["receiver.country"] = FormatInt(recipient.Country),
["receiver.province"] = Norm(recipient.Province),
["receiver.city"] = Norm(recipient.City),
["receiver.zipCode"] = Norm(recipient.ZipCode),
["receiver.address1"] = Norm(recipient.StreetAddress1),
["receiver.address2"] = Norm(recipient.StreetAddress2),
};
}
public static Dictionary<string, string?> CaptureWeights(IEnumerable<OrderWeight> weights)
{
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var row in weights)
{
var type = string.IsNullOrWhiteSpace(row.Type) ? "unknown" : row.Type.Trim().ToLowerInvariant();
var prefix = $"weight.{type}";
map[$"{prefix}.actualWeight"] = FormatDecimal(row.ActualWeight);
map[$"{prefix}.length"] = FormatDecimal(row.Length);
map[$"{prefix}.width"] = FormatDecimal(row.Width);
map[$"{prefix}.height"] = FormatDecimal(row.Height);
map[$"{prefix}.chargeableWeight"] = FormatDecimal(row.ChargeableWeight);
}
return map;
}
public static Dictionary<string, string?> CaptureFeeTotals(IEnumerable<OrderFee> fees)
{
var list = fees.ToList();
var receivable = list
.Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Receivable, StringComparison.OrdinalIgnoreCase))
.Sum(x => x.Amount);
var payable = list
.Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Payable, StringComparison.OrdinalIgnoreCase))
.Sum(x => x.Amount);
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["fee.receivableTotal"] = FormatDecimal(receivable),
["fee.payableTotal"] = FormatDecimal(payable),
};
}
public static Dictionary<string, string?> Merge(params IReadOnlyDictionary<string, string?>[] parts)
{
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var part in parts)
{
foreach (var (key, value) in part)
{
map[key] = value;
}
}
return map;
}
public static List<(string Path, string Label, string? Before, string? After)> Diff(
IReadOnlyDictionary<string, string?> before,
IReadOnlyDictionary<string, string?> after)
{
var keys = before.Keys.Union(after.Keys, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
var changes = new List<(string, string, string?, string?)>();
foreach (var key in keys)
{
before.TryGetValue(key, out var b);
after.TryGetValue(key, out var a);
b = Norm(b);
a = Norm(a);
if (string.Equals(b, a, StringComparison.Ordinal))
{
continue;
}
changes.Add((key, ResolveLabel(key), b, a));
}
return changes;
}
public static string ResolveLabel(string path)
{
if (Labels.TryGetValue(path, out var label))
{
return label;
}
if (path.StartsWith("weight.", StringComparison.OrdinalIgnoreCase))
{
var parts = path.Split('.');
if (parts.Length >= 3)
{
var typeName = parts[1] switch
{
"forecast" => "预报",
"receive" => "收货",
"ship" => "发货",
"downstream" => "对账",
_ => parts[1]
};
var fieldName = parts[2] switch
{
"actualWeight" => "实重",
"length" => "长",
"width" => "宽",
"height" => "高",
"chargeableWeight" => "计费重",
_ => parts[2]
};
return $"{typeName}{fieldName}";
}
}
return path;
}
/// <summary>
/// 拼装列表「内容」列:备注优先,再跟最多 N 条字段变更。
/// </summary>
public static string BuildSummary(
IReadOnlyList<OrderOperationLogChangeView> changes,
string? remark,
int maxItems = SummaryMaxChangeItems)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(remark))
{
parts.Add(remark.Trim());
}
if (changes.Count > 0)
{
var take = Math.Max(0, maxItems);
foreach (var change in changes.Take(take))
{
var label = string.IsNullOrWhiteSpace(change.Label) ? change.Path : change.Label;
parts.Add($"{label}:{DisplayValue(change.Path, change.Before)}→{DisplayValue(change.Path, change.After)}");
}
if (changes.Count > take)
{
parts.Add($"等{changes.Count}项变更");
}
}
return parts.Count == 0 ? "—" : string.Join(";", parts);
}
public readonly record struct OrderOperationLogChangeView(
string Path,
string Label,
string? Before,
string? After);
private static string DisplayValue(string path, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "空";
}
if (string.Equals(path, "isPaid", StringComparison.OrdinalIgnoreCase)
|| string.Equals(path, "isCod", StringComparison.OrdinalIgnoreCase))
{
return value == "1" ? "是" : "否";
}
if (string.Equals(path, "orderStatus", StringComparison.OrdinalIgnoreCase))
{
return OmsDocHelper.GetOrderStatusName(value) ?? value;
}
return value;
}
private static string? FormatAdditionalServices(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
var list = JsonConvert.DeserializeObject<List<string>>(json) ?? [];
var codes = list
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
return codes.Count == 0 ? null : string.Join("、", codes);
}
catch
{
return Norm(json);
}
}
private static string? Norm(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static string? FormatDecimal(decimal? value)
{
if (!value.HasValue)
{
return null;
}
return value.Value.ToString("0.####", CultureInfo.InvariantCulture);
}
private static string? FormatInt(int? value) => value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : null;
private static string FormatByte(byte value) => value == 0 ? "0" : "1";
}

View File

@ -0,0 +1,193 @@
using Fake.DependencyInjection;
using Fake.Security.Claims;
using Fake.Users;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 运单操作日志写入器:字段 diff 落库;失败不影响主业务。
/// </summary>
public class OrderOperationLogWriter : IScopedDependency
{
private readonly IOrderOperationLogRepository _repository;
private readonly ICurrentUser _currentUser;
private readonly OmsRbacUserDisplayNameResolver _userDisplayNameResolver;
private readonly ILogger<OrderOperationLogWriter> _logger;
public OrderOperationLogWriter(
IOrderOperationLogRepository repository,
ICurrentUser currentUser,
OmsRbacUserDisplayNameResolver userDisplayNameResolver,
ILogger<OrderOperationLogWriter> logger)
{
_repository = repository;
_currentUser = currentUser;
_userDisplayNameResolver = userDisplayNameResolver;
_logger = logger;
}
public Task TryWriteAsync(
Order order,
string action,
IReadOnlyDictionary<string, string?> before,
IReadOnlyDictionary<string, string?> after,
string? remark = null)
=> TryWriteAsync(order, action, before, after, remark, operatorId: null, operatorName: null);
public async Task TryWriteAsync(
Order order,
string action,
IReadOnlyDictionary<string, string?> before,
IReadOnlyDictionary<string, string?> after,
string? remark,
int? operatorId,
string? operatorName,
string operatorType = "staff",
string source = "pc")
{
try
{
var changes = OrderOperationLogSnapshot.Diff(before, after);
var (resolvedId, resolvedName) = await ResolveOperatorAsync(operatorId, operatorName);
var entity = new OrderOperationLog
{
OrderId = order.Id,
ServiceNo = order.ServiceNo,
Action = action,
ActionName = OrderOperationActionCodes.GetActionName(action),
OperatorId = resolvedId,
OperatorName = resolvedName,
OperatorType = string.IsNullOrWhiteSpace(operatorType) ? "staff" : operatorType.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "pc" : source.Trim(),
ChangesJson = JsonConvert.SerializeObject(
changes.Select(x => new OrderOperationLogChangeItem
{
Path = x.Path,
Label = x.Label,
Before = x.Before,
After = x.After
}).ToList()),
Remark = string.IsNullOrWhiteSpace(remark) ? null : remark.Trim(),
CreateUserId = resolvedId,
CreateTime = DateTime.Now
};
await _repository.InsertAsync(entity);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"写入运单操作日志失败 OrderId={OrderId} Action={Action}",
order.Id,
action);
}
}
/// <summary>
/// 无字段 diff 时写入叙事日志(打印、下游预报成功等)。
/// </summary>
public Task TryWriteSimpleAsync(
Order order,
string action,
string? remark,
int? operatorId = null,
string? operatorName = null,
string operatorType = "staff",
string source = "pc")
=> TryWriteAsync(
order,
action,
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
remark,
operatorId,
operatorName,
operatorType,
source);
public async Task<OrderOperationLogListOutput> ListByOrderIdAsync(int orderId)
{
var rows = await _repository.GetListAsync(x => x.OrderId == orderId);
var items = rows
.OrderByDescending(x => x.CreateTime)
.ThenByDescending(x => x.Id)
.Select(MapOutput)
.ToList();
return new OrderOperationLogListOutput { Items = items };
}
private async Task<(int OperatorId, string? OperatorName)> ResolveOperatorAsync(
int? operatorId,
string? operatorName)
{
if (operatorId is > 0)
{
var name = string.IsNullOrWhiteSpace(operatorName)
? await _userDisplayNameResolver.ResolveAsync(operatorId.Value)
: operatorName.Trim();
return (operatorId.Value, name);
}
if (_currentUser.IsAuthenticated)
{
var id = _currentUser.GetId<int>();
var name = string.IsNullOrWhiteSpace(operatorName)
? await _userDisplayNameResolver.ResolveAsync(id)
: operatorName.Trim();
return (id, name);
}
return (0, string.IsNullOrWhiteSpace(operatorName) ? null : operatorName.Trim());
}
private static OrderOperationLogOutput MapOutput(OrderOperationLog row)
{
var changes = new List<OrderOperationLogChangeItem>();
if (!string.IsNullOrWhiteSpace(row.ChangesJson))
{
try
{
changes = JsonConvert.DeserializeObject<List<OrderOperationLogChangeItem>>(row.ChangesJson)
?? [];
}
catch
{
changes = [];
}
}
var views = changes
.Select(x => new OrderOperationLogSnapshot.OrderOperationLogChangeView(
x.Path,
x.Label,
x.Before,
x.After))
.ToList();
return new OrderOperationLogOutput
{
Id = row.Id,
OrderId = row.OrderId,
ServiceNo = row.ServiceNo,
Action = row.Action,
ActionName = row.ActionName,
OperatorId = row.OperatorId,
OperatorName = row.OperatorName,
OperatorType = row.OperatorType,
Source = row.Source,
Changes = changes,
Remark = row.Remark,
Summary = OrderOperationLogSnapshot.BuildSummary(views, row.Remark),
CreateTime = row.CreateTime
};
}
}

View File

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

View File

@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private OrderOperationLogWriter OperationLogWriter =>
LazyServiceProvider.GetRequiredService<OrderOperationLogWriter>();
/// <summary>
/// 运单操作日志列表(按时间倒序)。
/// </summary>
[HttpGet("/order/operation-log/list")]
public async Task<OrderOperationLogListOutput> GetOperationLogListAsync([FromQuery] int orderId)
{
await GetOrderOrThrowAsync(orderId);
return await OperationLogWriter.ListByOrderIdAsync(orderId);
}
private async Task<Dictionary<string, string?>> CaptureOrderAuditSnapshotAsync(
Order order,
bool includeParties = false,
bool includeWeights = false,
bool includeFees = false)
{
var parts = new List<IReadOnlyDictionary<string, string?>>
{
OrderOperationLogSnapshot.CaptureOrder(order)
};
if (includeParties)
{
var senders = await OrderRepository.GetSendersByOrderIdsAsync([order.Id]);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
parts.Add(OrderOperationLogSnapshot.CaptureSender(senders.FirstOrDefault()));
parts.Add(OrderOperationLogSnapshot.CaptureReceiver(recipients.FirstOrDefault()));
}
if (includeWeights)
{
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
parts.Add(OrderOperationLogSnapshot.CaptureWeights(weights));
}
if (includeFees)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
parts.Add(OrderOperationLogSnapshot.CaptureFeeTotals(fees));
}
return OrderOperationLogSnapshot.Merge(parts.ToArray());
}
}

View File

@ -1,3 +1,4 @@
using System.Globalization;
using Fake.Application; using Fake.Application;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -28,6 +29,7 @@ public partial class OrderService
public async Task<OrderOutput> UpdateOrderDetailAsync(OrderUpdateInput input) public async Task<OrderOutput> UpdateOrderDetailAsync(OrderUpdateInput input)
{ {
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true);
if (input.ClientNo != null) if (input.ClientNo != null)
{ {
@ -134,6 +136,16 @@ public partial class OrderService
input.Items.Select(x => MapDeclarationInput(order.Id, x))); input.Items.Select(x => MapDeclarationInput(order.Id, x)));
} }
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true);
var remark = input.Items != null ? "申报信息已更新" : null;
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.EditBasic,
beforeSnap,
afterSnap,
remark);
return (await MapOrderOutputsAsync([order])).First(); return (await MapOrderOutputsAsync([order])).First();
} }
@ -149,6 +161,7 @@ public partial class OrderService
} }
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
if (string.IsNullOrWhiteSpace(order.ProductCode)) if (string.IsNullOrWhiteSpace(order.ProductCode))
{ {
throw new BusinessException("订单未绑定销售产品,无法重算计费重"); throw new BusinessException("订单未绑定销售产品,无法重算计费重");
@ -224,6 +237,14 @@ public partial class OrderService
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.EditWeight,
beforeSnap,
afterSnap);
return (await MapOrderOutputsAsync([order])).First(); return (await MapOrderOutputsAsync([order])).First();
} }
@ -234,8 +255,16 @@ public partial class OrderService
public async Task<OrderOutput> RecalculateReceivableFeesAsync(OrderActionInput input) public async Task<OrderOutput> RecalculateReceivableFeesAsync(OrderActionInput input)
{ {
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
EnsureReceivableMutable(order); EnsureReceivableMutable(order);
await RecalculateReceivableCoreAsync(order); await RecalculateReceivableCoreAsync(order);
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.RecalcReceivable,
beforeSnap,
afterSnap);
return (await MapOrderOutputsAsync([order])).First(); return (await MapOrderOutputsAsync([order])).First();
} }
@ -246,10 +275,141 @@ public partial class OrderService
public async Task<OrderOutput> RecalculatePayableFeesAsync(OrderActionInput input) public async Task<OrderOutput> RecalculatePayableFeesAsync(OrderActionInput input)
{ {
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await RecalculatePayableCoreAsync(order); await RecalculatePayableCoreAsync(order);
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.RecalcPayable,
beforeSnap,
afterSnap);
return (await MapOrderOutputsAsync([order])).First(); 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($"规则版本 {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($"价格版本 {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);
remarkParts.Add("并重算应收");
}
if (input.RecalculatePayable)
{
await RecalculatePayableCoreAsync(order);
remarkParts.Add("并重算应付");
}
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> /// <summary>
/// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。 /// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。
/// </summary> /// </summary>
@ -566,8 +726,17 @@ public partial class OrderService
public async Task<OrderOutput> ReallocateAsync([FromBody] OrderReallocateInput input) public async Task<OrderOutput> ReallocateAsync([FromBody] OrderReallocateInput input)
{ {
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId); await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId);
order = await GetOrderOrThrowAsync(input.Id); order = await GetOrderOrThrowAsync(input.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
var remark = string.IsNullOrWhiteSpace(input.Scheme) ? null : $"scheme={input.Scheme.Trim()}";
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.Reallocate,
beforeSnap,
afterSnap,
remark);
return (await MapOrderOutputsAsync([order])).First(); return (await MapOrderOutputsAsync([order])).First();
} }
@ -580,12 +749,22 @@ public partial class OrderService
NormalizeReallocateOptions(input.Scheme, input.ServiceChannelId, out var scheme, out var channelId); NormalizeReallocateOptions(input.Scheme, input.ServiceChannelId, out var scheme, out var channelId);
var result = new OrderBatchActionResultOutput(); var result = new OrderBatchActionResultOutput();
var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList(); var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList();
var remark = string.IsNullOrWhiteSpace(scheme) ? null : $"scheme={scheme}";
foreach (var orderId in orderIds) foreach (var orderId in orderIds)
{ {
try try
{ {
var order = await GetOrderOrThrowAsync(orderId); var order = await GetOrderOrThrowAsync(orderId);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await ReallocateCoreAsync(order, scheme, channelId); await ReallocateCoreAsync(order, scheme, channelId);
order = await GetOrderOrThrowAsync(orderId);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.Reallocate,
beforeSnap,
afterSnap,
remark);
result.SuccessCount++; result.SuccessCount++;
} }
catch (Exception ex) catch (Exception ex)
@ -609,6 +788,7 @@ public partial class OrderService
{ {
var order = await GetOrderOrThrowAsync(input.Id); var order = await GetOrderOrThrowAsync(input.Id);
EnsureReceivableMutable(order); EnsureReceivableMutable(order);
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
if (string.IsNullOrWhiteSpace(order.ProductCode)) if (string.IsNullOrWhiteSpace(order.ProductCode))
{ {
@ -627,6 +807,15 @@ public partial class OrderService
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.UpdateAdditionalServices,
beforeSnap,
afterSnap);
return (await MapOrderOutputsAsync([order])).First(); return (await MapOrderOutputsAsync([order])).First();
} }
@ -716,6 +905,8 @@ public partial class OrderService
throw new BusinessException("计费阶段无效"); throw new BusinessException("计费阶段无效");
} }
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
var fee = new OrderFee var fee = new OrderFee
{ {
Type = type, Type = type,
@ -736,6 +927,17 @@ public partial class OrderService
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
var typeName = type == OrderFeeTypeCodes.Receivable ? "应收" : "应付";
var amountText = input.Amount.ToString("0.####", CultureInfo.InvariantCulture);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.AddFee,
beforeSnap,
afterSnap,
$"新增{typeName}「{costItem}」{currency} {amountText}");
return (await MapOrderOutputsAsync([order])).First(); return (await MapOrderOutputsAsync([order])).First();
} }
@ -955,6 +1157,7 @@ public partial class OrderService
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。 // 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
// 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。 // 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。
// 锁渠道重算应付:允许渠道不在旧规则排货池内(渠道已确认,只重算买价)。
var snapshotBinding = enforceReallocateGate var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now) ? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order); : await ResolveSnapshotBindingFromOrderAsync(product, order);
@ -977,7 +1180,14 @@ public partial class OrderService
billing, billing,
chargingStage, chargingStage,
schemeOverride, schemeOverride,
forceChannelId); forceChannelId,
allowDesignatedOutsidePool: lockCurrentChannel);
// 重新排货成功后写回规则版本,使后续重算应付与当前排货池一致;卖价版本保持契约,不随排货改写。
if (enforceReallocateGate)
{
order.SalesProductRulesVersion = snapshotBinding.RulesVersion;
}
if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase)) if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
{ {
@ -990,15 +1200,15 @@ public partial class OrderService
private static void EnsureReallocateAllowed(Order order) private static void EnsureReallocateAllowed(Order order)
{ {
if (order.IsPaid != 0) // 已付款也可排货:只重算应付与服务渠道,不改应收。
{ if (order.OrderStatus is not (
throw new BusinessException("订单已付款,不允许重新排货"); OrderStatusCodes.Predeclared
} or OrderStatusCodes.PendingAllocation
or OrderStatusCodes.PendingPayment
if (order.OrderStatus is not (OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment)) or OrderStatusCodes.PendingShip))
{ {
throw new BusinessException( throw new BusinessException(
$"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅待排货/待支付可重新排货"); $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅已预报/待排货/待支付/待发货可重新排货");
} }
} }

View File

@ -9,6 +9,8 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Labeling; using Oms.Application.Labeling;
using Oms.Application.Messaging; using Oms.Application.Messaging;
using Oms.Application.Messaging.OrderSideEffects;
using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Application.Storage; using Oms.Application.Storage;
using Oms.Application.Services.Orders.Batch; using Oms.Application.Services.Orders.Batch;
@ -36,6 +38,8 @@ public partial class OrderService
private const int ParcelBatchMaxCount = 2000; private const int ParcelBatchMaxCount = 2000;
private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingProcessingReason = "入库后计费/排货处理中";
private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费";
private const string ForecastBillingProcessingReason = "预报计费/排货处理中";
private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报";
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new(); private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new();
@ -143,10 +147,11 @@ public partial class OrderService
// 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。 // 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。
if (input.SaveAsDraft) if (input.SaveAsDraft)
{ {
await TryUpsertCustoms9610Async(order, input.PlatformCode); await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode);
return await BuildParcelCreateOutputAsync(order); return await BuildParcelCreateOutputAsync(order);
} }
// 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。
order.IsRemote = await IsRemoteHitAsync( order.IsRemote = await IsRemoteHitAsync(
product, product,
snapshotBinding.Rules, snapshotBinding.Rules,
@ -154,62 +159,24 @@ public partial class OrderService
input.Receiver?.Province, input.Receiver?.Province,
input.Receiver?.City, input.Receiver?.City,
input.Receiver?.ZipCode) ? (byte)1 : (byte)0; input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
order.OrderStatus = OrderStatusCodes.Draft;
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast); order.PredeclareFailReason = ForecastBillingProcessingReason;
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); order.AllocationPendingReason = string.Empty;
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
await TryUpsertCustoms9610Async(order, input.PlatformCode);
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId);
var billing = await ResolveBillingContextAsync(
product,
snapshotBinding,
new OrderBillingAddress(
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode),
forecast.ChargeableWeight ?? 0,
order.CargoAttribute ?? CargoAttributeCodes.General,
order.WarehouseId,
priceTierKey);
await UpsertReceivableFeesAsync(
order,
billing,
OrderFeeChargingStageCodes.Forecast);
// 预报阶段尝试排货写应付;失败不阻断创建(仅记录 AllocationPendingReason)。
await TryConfirmServiceChannelAndPayableAsync(
order,
product,
billing,
OrderFeeChargingStageCodes.Forecast);
await ApplyMarketingSnapshotOnCreateAsync(order, input.CouponInstanceId);
await CaptureForecastBillingSnapshotAsync(order);
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver, appendInitialTrack: true); await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode);
await EnqueueGenerateLabelPdfAsync(order); await EnqueueForecastBillingAsync(
order,
input.CouponInstanceId,
operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0));
try var output = await BuildParcelCreateOutputAsync(order);
{ output.ForecastBillingQueued = string.Equals(
var triggerUserId = operatorUserId ?? CurrentUser.GetId<int>(); order.PredeclareFailReason,
await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId); ForecastBillingProcessingReason,
} StringComparison.Ordinal);
catch return output;
{
// 自动触发失败不影响预报主流程
}
return await BuildParcelCreateOutputAsync(order);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -848,7 +815,8 @@ public partial class OrderService
string chargingStage, string chargingStage,
string? _, string? _,
string? schemeOverride = null, string? schemeOverride = null,
int? forceServiceChannelId = null) int? forceServiceChannelId = null,
bool allowDesignatedOutsidePool = false)
{ {
var evaluation = await EvaluateAllocationAsync( var evaluation = await EvaluateAllocationAsync(
order, order,
@ -858,7 +826,8 @@ public partial class OrderService
chargeableWeight, chargeableWeight,
chargingStage, chargingStage,
schemeOverride, schemeOverride,
forceServiceChannelId); forceServiceChannelId,
allowDesignatedOutsidePool);
ApplyAllocationTraceToOrder(order, evaluation); ApplyAllocationTraceToOrder(order, evaluation);
@ -928,7 +897,8 @@ public partial class OrderService
decimal chargeableWeight, decimal chargeableWeight,
string chargingStage, string chargingStage,
string? schemeOverride, string? schemeOverride,
int? forceServiceChannelId) int? forceServiceChannelId,
bool allowDesignatedOutsidePool = false)
{ {
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
@ -985,27 +955,61 @@ public partial class OrderService
if (designated.Count == 0) if (designated.Count == 0)
{ {
var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value); var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value);
var failReason = poolRow == null // 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。
? "指定渠道不在该销售产品排货池" if (allowDesignatedOutsidePool)
: "指定渠道不满足判断条件";
if (poolRow != null
&& skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
{ {
skipped.Add(new OrderAllocationSkippedOutput var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
x.Id == forceServiceChannelId.Value);
if (lockedChannel == null)
{ {
ServiceChannelId = forceServiceChannelId.Value, return BaseEval("订单服务渠道不存在,无法重算应付");
ChannelCode = poolRow.ChannelCode, }
ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
Stage = AllocationResolver.SkipStageCondition, var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto
MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition), {
Reason = failReason 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) if (filtered.Count == 0)
@ -1304,7 +1308,8 @@ public partial class OrderService
OrderBillingContext billing, OrderBillingContext billing,
string chargingStage, string chargingStage,
string? schemeOverride = null, string? schemeOverride = null,
int? forceServiceChannelId = null) => int? forceServiceChannelId = null,
bool allowDesignatedOutsidePool = false) =>
ConfirmServiceChannelAndPayableAsync( ConfirmServiceChannelAndPayableAsync(
order, order,
product, product,
@ -1314,7 +1319,8 @@ public partial class OrderService
chargingStage, chargingStage,
billing.PriceTierKey, billing.PriceTierKey,
schemeOverride, schemeOverride,
forceServiceChannelId); forceServiceChannelId,
allowDesignatedOutsidePool);
/// <summary> /// <summary>
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。 /// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
@ -1325,7 +1331,8 @@ public partial class OrderService
OrderBillingContext billing, OrderBillingContext billing,
string chargingStage, string chargingStage,
string? schemeOverride = null, string? schemeOverride = null,
int? forceServiceChannelId = null) int? forceServiceChannelId = null,
bool allowDesignatedOutsidePool = false)
{ {
try try
{ {
@ -1335,7 +1342,8 @@ public partial class OrderService
billing, billing,
chargingStage, chargingStage,
schemeOverride, schemeOverride,
forceServiceChannelId); forceServiceChannelId,
allowDesignatedOutsidePool);
return true; return true;
} }
catch (BusinessException ex) catch (BusinessException ex)
@ -2246,8 +2254,7 @@ public partial class OrderService
} }
/// <summary> /// <summary>
/// 草稿重新预报:按订单当前收件/件重尺/快照重跑黑名单、计重、分区与预报应收。 /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。
/// 失败时保持草稿并写入 PredeclareFailReason。
/// </summary> /// </summary>
internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null) internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null)
{ {
@ -2278,63 +2285,52 @@ public partial class OrderService
throw new BusinessException("收件地址命中黑名单区域"); throw new BusinessException("收件地址命中黑名单区域");
} }
order.IsRemote = await IsRemoteHitAsync( var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
product, if (!weights.Any(x =>
snapshotBinding.Rules, string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase)))
address.CountryCode, {
address.Province, throw new BusinessException("订单缺少预报件重尺");
address.City, }
address.ZipCode) ? (byte)1 : (byte)0;
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) order.OrderStatus = OrderStatusCodes.Draft;
.OrderBy(x => x.Id) order.PredeclareFailReason = ForecastBillingProcessingReason;
.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.AllocationPendingReason = string.Empty;
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
var customer = order.CustomerId.HasValue await EnqueueForecastBillingAsync(
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) order,
: null; order.CouponInstanceId,
await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null, appendInitialTrack: true); operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0));
await EnqueueGenerateLabelPdfAsync(order);
try // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。
var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id);
if (refreshed != null
&& string.Equals(refreshed.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(refreshed.PredeclareFailReason)
&& !string.Equals(
refreshed.PredeclareFailReason,
ForecastBillingProcessingReason,
StringComparison.Ordinal))
{ {
var triggerUserId = operatorUserId ?? CurrentUser.GetId<int>(); throw CreateDraftSavedBusinessException(
await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId); refreshed,
} StripDraftSavedMarker(refreshed.PredeclareFailReason));
catch
{
// 自动触发失败不影响预报主流程
} }
} }
catch (BusinessException ex) catch (BusinessException ex)
{ {
if (string.Equals(ex.ErrorCode, OrderParcelDraftFailData.ErrorCodeValue, StringComparison.Ordinal))
{
throw;
}
var cleanReason = StripDraftSavedMarker(ex.Message); var cleanReason = StripDraftSavedMarker(ex.Message);
await MarkParcelDraftFailedAsync(order, cleanReason); if (!string.Equals(cleanReason, ForecastBillingProcessingReason, StringComparison.Ordinal))
{
await MarkParcelDraftFailedAsync(order, cleanReason);
}
throw CreateDraftSavedBusinessException(order, cleanReason); throw CreateDraftSavedBusinessException(order, cleanReason);
} }
catch (Exception ex) catch (Exception ex)
@ -2423,7 +2419,7 @@ public partial class OrderService
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode); await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.InboundTrackCode);
await EnqueueInboundBillingAsync(order); await EnqueueInboundBillingAsync(order);
} }
finally finally
@ -2605,6 +2601,270 @@ public partial class OrderService
return text[..512]; return text[..512];
} }
/// <summary>
/// 预报计费/排货异步任务(创单与草稿预报共用)。
/// </summary>
internal async Task ProcessForecastBillingForBatchAsync(
int orderId,
string? payloadJson = null,
int operatorUserId = 0,
CancellationToken cancellationToken = default)
{
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
if (order == null)
{
return;
}
// 已预报成功则跳过(幂等)
if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(order.PredeclareFailReason))
{
return;
}
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
int? couponInstanceId = order.CouponInstanceId;
if (!string.IsNullOrWhiteSpace(payloadJson))
{
try
{
var payload = JsonConvert.DeserializeObject<ForecastBillingJobPayload>(payloadJson);
if (payload?.CouponInstanceId is > 0)
{
couponInstanceId = payload.CouponInstanceId;
}
}
catch
{
// ignore bad payload
}
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
await MarkParcelDraftFailedAsync(order, "订单未绑定销售产品,无法预报");
return;
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null || product.IsActive != EnableStatus.Enabled)
{
await MarkParcelDraftFailedAsync(order, "销售产品不存在或未启用");
return;
}
ProductSnapshotBinding snapshotBinding;
OrderBillingAddress address;
try
{
snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
address = await ResolveOrderBillingAddressAsync(order);
order.IsRemote = await IsRemoteHitAsync(
product,
snapshotBinding.Rules,
address.CountryCode,
address.Province,
address.City,
address.ZipCode) ? (byte)1 : (byte)0;
}
catch (Exception ex)
{
await MarkParcelDraftFailedAsync(
order,
ex is BusinessException be ? be.Message : $"预报快照解析失败:{ex.Message}");
return;
}
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));
if (forecast == null)
{
await MarkParcelDraftFailedAsync(order, "订单缺少预报件重尺");
return;
}
try
{
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
}
catch (Exception ex)
{
await MarkParcelDraftFailedAsync(
order,
ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}");
return;
}
OrderBillingContext billing;
try
{
billing = await ResolveOrderBillingContextAsync(
order,
product,
forecast.ChargeableWeight ?? 0,
snapshotBinding);
}
catch (Exception ex)
{
await MarkParcelDraftFailedAsync(
order,
ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}");
return;
}
try
{
await UpsertReceivableFeesAsync(
order,
billing,
OrderFeeChargingStageCodes.Forecast,
replaceExistingReceivable: true);
}
catch (Exception ex)
{
await MarkParcelDraftFailedAsync(
order,
ex is BusinessException be ? be.Message : $"预报应收计费失败:{ex.Message}");
return;
}
// 排货失败不阻断预报成功(与原创单同步路径一致)
await TryConfirmServiceChannelAndPayableAsync(
order,
product,
billing,
OrderFeeChargingStageCodes.Forecast);
try
{
await ApplyMarketingSnapshotOnCreateAsync(order, couponInstanceId);
}
catch (Exception ex)
{
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("Oms.ForecastBilling");
logger.LogWarning(ex, "预报营销快照失败 OrderId={OrderId}", order.Id);
}
try
{
await CaptureForecastBillingSnapshotAsync(order);
}
catch
{
// ignore
}
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.PreDeclare,
beforeSnap,
afterSnap,
"预报计费/排货完成",
operatorUserId > 0 ? operatorUserId : null,
operatorName: null,
operatorType: "staff",
source: "job");
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await PublishParcelCreateTrackAndLabelAsync(
order,
customer,
receiver: null,
appendInitialTrack: true,
operatorUserId);
AutoTriggerRuntimeService.EnqueueFire(
order.Id,
AutoTriggerTiming.OrderCreate,
operatorUserId);
}
private sealed class ForecastBillingJobPayload
{
public int? CouponInstanceId { get; set; }
}
private async Task EnqueueForecastBillingAsync(
Order order,
int? couponInstanceId,
int operatorUserId)
{
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("Oms.ForecastBilling");
var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
{
CouponInstanceId = couponInstanceId
});
try
{
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
if (!publisher.IsEnabled)
{
try
{
await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorUserId);
}
catch (Exception ex)
{
logger.LogError(
ex,
"预报计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
await MarkParcelDraftFailedAsync(
order,
TrimPendingReason($"预报计费失败:{ex.Message}"));
}
return;
}
var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}";
await BatchJobCoordinator.CreateAndEnqueueAsync(
new OrderBatchJobCreateInput
{
Action = OrderBatchActionCodes.ForecastBilling,
OrderIds = [order.Id],
RequestId = requestId,
PayloadJson = payloadJson
},
operatorUserId);
}
catch (Exception ex)
{
logger.LogError(
ex,
"预报计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
try
{
await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason);
}
catch
{
// ignore
}
}
}
private async Task EnqueueInboundBillingAsync(Order order) private async Task EnqueueInboundBillingAsync(Order order)
{ {
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>() var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
@ -2778,7 +3038,7 @@ public partial class OrderService
order.UpdateTime = now; order.UpdateTime = now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.OutboundTrackCode); await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.OutboundTrackCode);
} }
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order) private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
@ -2836,46 +3096,56 @@ public partial class OrderService
TrackNo = order.TrackNo, TrackNo = order.TrackNo,
ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountry = order.ConsigneeCountry,
ConsigneeCountryName = order.ConsigneeCountryName, ConsigneeCountryName = order.ConsigneeCountryName,
LabelUrl = labelUrl LabelUrl = labelUrl,
OrderStatus = order.OrderStatus,
PredeclareFailReason = order.PredeclareFailReason,
ForecastBillingQueued = string.Equals(
order.PredeclareFailReason,
ForecastBillingProcessingReason,
StringComparison.Ordinal)
}; };
} }
private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier => private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderNotifier>(); LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
private async Task EnqueueGenerateLabelPdfAsync(Order order) private IOrderSideEffectEventPublisher SideEffectEventPublisher =>
LazyServiceProvider.GetRequiredService<IOrderSideEffectEventPublisher>();
/// <summary>
/// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。
/// </summary>
private async Task PublishParcelCreateTrackAndLabelAsync(
Order order,
Customer? customer,
OrderParcelReceiverInput? receiver,
bool appendInitialTrack,
int? operatorUserId = null)
{ {
try var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
request.AppendInitialTrackOnSuccess = appendInitialTrack;
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{ {
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>(); request.Authorization = authorization;
if (!publisher.IsEnabled) }
{
// 本地未开队列:同步生成,避免 Pending 悬挂
var mapped = (await MapOrderOutputsAsync([order])).First();
await LabelPdfGenerationService.GenerateAndPersistAsync(
order,
mapped,
operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
return;
}
await BatchJobCoordinator.CreateAndEnqueueAsync( await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
new OrderBatchJobCreateInput await ParcelCreateEventPublisher.PublishLabelGenerateAsync(
{ order,
Action = OrderBatchActionCodes.GenerateLabelPdf, operatorUserId
OrderIds = [order.Id], ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0));
RequestId = $"label-pdf:{order.Id}:{Guid.NewGuid():N}"
},
CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
}
catch (Exception)
{
// 投递失败不阻断创单;打印时会补生成
}
} }
/// <summary> /// <summary>
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
/// 入库等非创单路径仍可调用;创单路径请走 <see cref="PublishParcelCreateTrackAndLabelAsync"/>。
/// </summary> /// </summary>
private async Task EnqueueSmallBagTrackUpsertAsync( private async Task EnqueueSmallBagTrackUpsertAsync(
Order order, Order order,
@ -2897,10 +3167,10 @@ public partial class OrderService
request.Authorization = authorization; request.Authorization = authorization;
} }
SmallBagTrackOrderNotifier.Enqueue(request, order.Id, customer?.Id); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
} }
private void EnqueueLifecycleTrackAppend(Order order, string trackCode) private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode)
{ {
if (string.IsNullOrWhiteSpace(order.ServiceNo)) if (string.IsNullOrWhiteSpace(order.ServiceNo))
{ {
@ -2915,10 +3185,10 @@ public partial class OrderService
.Authorization .Authorization
.ToString(); .ToString();
SmallBagTrackOrderNotifier.EnqueueLifecycleTrackAppend( await SideEffectEventPublisher.PublishLifecycleTrackAppendAsync(
order.Id,
order.ServiceNo, order.ServiceNo,
trackCode, trackCode,
order.Id,
string.IsNullOrWhiteSpace(authorization) ? null : authorization); string.IsNullOrWhiteSpace(authorization) ? null : authorization);
} }

View File

@ -1,3 +1,4 @@
using System.Globalization;
using Fake.Application; using Fake.Application;
using Fake.Domain.Exceptions; using Fake.Domain.Exceptions;
using Fake.ObjectStorage; using Fake.ObjectStorage;
@ -11,6 +12,7 @@ using Oms.Application.Labeling;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Application.Storage; using Oms.Application.Storage;
using Newtonsoft.Json; using Newtonsoft.Json;
using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Services.Orders.Batch; using Oms.Application.Services.Orders.Batch;
using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories;
@ -146,6 +148,7 @@ public partial class OrderService : ApplicationService
throw new BusinessException("订单未关联客户,无法支付"); throw new BusinessException("订单未关联客户,无法支付");
} }
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var payAmount = order.PayableAmount ?? fees var payAmount = order.PayableAmount ?? fees
.Where(x => x.Type == OrderFeeTypeCodes.Receivable) .Where(x => x.Type == OrderFeeTypeCodes.Receivable)
@ -188,6 +191,16 @@ public partial class OrderService : ApplicationService
order.IsPaid = 1; order.IsPaid = 1;
order.OrderStatus = OrderStatusCodes.PendingShip; order.OrderStatus = OrderStatusCodes.PendingShip;
await UpdateOrderAsync(order); await UpdateOrderAsync(order);
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture);
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.Pay,
beforeSnap,
afterSnap,
$"支付金额 {amountText}");
} }
/// <summary> /// <summary>
@ -345,6 +358,10 @@ public partial class OrderService : ApplicationService
var existingUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id); var existingUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
if (!string.IsNullOrWhiteSpace(existingUrl)) if (!string.IsNullOrWhiteSpace(existingUrl))
{ {
await OperationLogWriter.TryWriteSimpleAsync(
order,
OrderOperationActionCodes.Print,
"打印面单(使用已有文件)");
return new OrderPrintOutput return new OrderPrintOutput
{ {
PrintUrl = existingUrl!, PrintUrl = existingUrl!,
@ -364,6 +381,10 @@ public partial class OrderService : ApplicationService
{ {
// 兼容回落:仍无模板时给 HTML stub,避免彻底不可打 // 兼容回落:仍无模板时给 HTML stub,避免彻底不可打
var fallbackUrl = await CreateOrderPrintFileAsync(mapped); var fallbackUrl = await CreateOrderPrintFileAsync(mapped);
await OperationLogWriter.TryWriteSimpleAsync(
order,
OrderOperationActionCodes.Print,
"打印面单(回落 HTML)");
return new OrderPrintOutput return new OrderPrintOutput
{ {
PrintUrl = fallbackUrl, PrintUrl = fallbackUrl,
@ -372,6 +393,10 @@ public partial class OrderService : ApplicationService
}; };
} }
await OperationLogWriter.TryWriteSimpleAsync(
order,
OrderOperationActionCodes.Print,
"打印面单");
return new OrderPrintOutput return new OrderPrintOutput
{ {
PrintUrl = result.LabelUrl!, PrintUrl = result.LabelUrl!,
@ -419,6 +444,21 @@ public partial class OrderService : ApplicationService
PrintUrl = url, PrintUrl = url,
LabelGenerateStatus = LabelGenerateStatusCodes.Ready LabelGenerateStatus = LabelGenerateStatusCodes.Ready
}); });
try
{
var existingOrder = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
if (existingOrder != null)
{
await OperationLogWriter.TryWriteSimpleAsync(
existingOrder,
OrderOperationActionCodes.Print,
"批量打印(使用已有文件)");
}
}
catch
{
// 日志失败不影响打印
}
} }
else else
{ {
@ -660,21 +700,24 @@ public partial class OrderService : ApplicationService
return; return;
} }
var publisher = LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
try try
{ {
var pickupService = LazyServiceProvider.GetRequiredService<PickupOrderService>(); // 优先入 Fake.EventBus;发布失败则同步回落建揽收并回写单号。
var pickup = await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput var queued = await publisher.PublishPickupAttachAsync(input, output.OrderId);
if (queued)
{ {
CustomerId = input.CustomerId, output.PickupQueued = true;
SenderAddressId = input.SenderAddressId.Value, return;
Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection), }
WarehouseId = input.WarehouseId.Value,
CarrierCode = input.CarrierCode, var pickup = await publisher.ExecutePickupAttachAsync(input, output.OrderId);
CarrierService = input.CarrierService, if (pickup == null)
ExpectPickupTime = input.ExpectPickupTime.Value, {
OrderIds = [output.OrderId], output.PickupCreateError = "揽收单创建失败";
Remark = null return;
}); }
output.PickupNo = pickup.PickupNo; output.PickupNo = pickup.PickupNo;
output.PickupOrderId = pickup.Id; output.PickupOrderId = pickup.Id;
output.CarrierWaybillNo = pickup.CarrierWaybillNo; output.CarrierWaybillNo = pickup.CarrierWaybillNo;

View File

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

View File

@ -33,6 +33,8 @@ public static class OrderBatchActionCodes
public const string Print = "print"; public const string Print = "print";
public const string GenerateLabelPdf = "generate-label-pdf"; public const string GenerateLabelPdf = "generate-label-pdf";
public const string InboundBilling = "inbound-billing"; public const string InboundBilling = "inbound-billing";
/// <summary>创单/草稿预报:预报应收计费 + 排货应付。</summary>
public const string ForecastBilling = "forecast-billing";
public const string Reallocate = "reallocate"; public const string Reallocate = "reallocate";
public const string Export = "export"; public const string Export = "export";
public const string ParcelCreate = "parcel-create"; public const string ParcelCreate = "parcel-create";

View File

@ -0,0 +1,34 @@
namespace Oms.Domain.OrdersAggregate.Consts;
public static class OrderOperationActionCodes
{
public const string EditBasic = "editBasic";
public const string EditWeight = "editWeight";
public const string RecalcReceivable = "recalcReceivable";
public const string RecalcPayable = "recalcPayable";
public const string Reallocate = "reallocate";
public const string BindSnapshotVersions = "bindSnapshotVersions";
public const string UpdateAdditionalServices = "updateAdditionalServices";
public const string AddFee = "addFee";
public const string Pay = "pay";
public const string PreDeclare = "preDeclare";
public const string DownstreamForward = "downstreamForward";
public const string Print = "print";
public static string GetActionName(string action) => action switch
{
EditBasic => "编辑订单详情",
EditWeight => "修改件重尺",
RecalcReceivable => "重算应收",
RecalcPayable => "重算应付",
Reallocate => "重新排货",
BindSnapshotVersions => "切换价格/规则版本",
UpdateAdditionalServices => "更新附加服务",
AddFee => "添加费用",
Pay => "支付",
PreDeclare => "重新预报",
DownstreamForward => "预报下游",
Print => "打印面单",
_ => action
};
}

View File

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

View File

@ -0,0 +1,36 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.OrdersAggregate.Entities;
[SugarTable("t_order_operation_log")]
public class OrderOperationLog : OmsAuditedAggregateRoot
{
public int OrderId { get; set; }
[SugarColumn(Length = 64, IsNullable = true)]
public string? ServiceNo { get; set; }
[SugarColumn(Length = 32)]
public string Action { get; set; } = string.Empty;
[SugarColumn(Length = 64)]
public string ActionName { get; set; } = string.Empty;
public int OperatorId { get; set; }
[SugarColumn(Length = 64, IsNullable = true)]
public string? OperatorName { get; set; }
[SugarColumn(Length = 16)]
public string OperatorType { get; set; } = "staff";
[SugarColumn(Length = 16)]
public string Source { get; set; } = "pc";
[SugarColumn(ColumnDataType = "json", IsNullable = true)]
public string? ChangesJson { get; set; }
[SugarColumn(Length = 512, IsNullable = true)]
public string? Remark { get; set; }
}

View File

@ -0,0 +1,8 @@
using Fake.Domain.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Domain.OrdersAggregate.Repositories;
public interface IOrderOperationLogRepository : IRepository<OrderOperationLog>
{
}

View File

@ -0,0 +1,13 @@
using Fake.DependencyInjection;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Orders;
public class OrderOperationLogRepository
: OmsSqlSugarRepository<OrderOperationLog>,
IOrderOperationLogRepository,
IScopedDependency
{
}