diff --git a/db.sql b/db.sql
index f55db57..3f1111e 100644
--- a/db.sql
+++ b/db.sql
@@ -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`;
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`;
@@ -117,6 +118,7 @@ CREATE TABLE `t_order` (
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode',
+ `DownstreamSupplierName` VARCHAR(128) NULL COMMENT '下游 API 服务商名称',
`DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey',
`DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id',
`ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode',
@@ -528,6 +530,29 @@ CREATE TABLE `t_order_exception_event` (
KEY `idx_oee_order_time` (`OrderId`, `CreateTime`)
) 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` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码',
diff --git a/sql/drop_all_unique_indexes.sql b/sql/drop_all_unique_indexes.sql
new file mode 100644
index 0000000..4f8d7cc
--- /dev/null
+++ b/sql/drop_all_unique_indexes.sql
@@ -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;
diff --git a/sql/migrate_20261009_order_warehouse.sql b/sql/migrate_20261009_order_warehouse.sql
new file mode 100644
index 0000000..c8c2b84
--- /dev/null
+++ b/sql/migrate_20261009_order_warehouse.sql
@@ -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;
diff --git a/sql/migrate_order_downstream_supplier_name.sql b/sql/migrate_order_downstream_supplier_name.sql
new file mode 100644
index 0000000..f3ba5ad
--- /dev/null
+++ b/sql/migrate_order_downstream_supplier_name.sql
@@ -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;
diff --git a/sql/migrate_order_operation_log.sql b/sql/migrate_order_operation_log.sql
new file mode 100644
index 0000000..094b880
--- /dev/null
+++ b/sql/migrate_order_operation_log.sql
@@ -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;
diff --git a/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs
new file mode 100644
index 0000000..2abfce4
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs
@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 切换订单绑定的销售产品规则/价格发布版本。
+///
+public class OrderBindSnapshotVersionsInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// 目标规则版本;空表示不改。特殊值 CURRENT 表示绑定当前有效规则发布。
+ ///
+ [StringLength(64)]
+ public string? RulesVersion { get; set; }
+
+ ///
+ /// 目标价格版本;空表示不改。特殊值 CURRENT 表示绑定当前有效价格发布。
+ ///
+ [StringLength(64)]
+ public string? PriceVersion { get; set; }
+
+ ///
+ /// 绑定后是否立即重算应收(已支付会拒绝)。
+ ///
+ public bool RecalculateReceivable { get; set; }
+
+ ///
+ /// 绑定后是否立即重算应付(锁定当前渠道)。
+ ///
+ public bool RecalculatePayable { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs
new file mode 100644
index 0000000..c5a3c84
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs
@@ -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 Changes { get; set; } = [];
+
+ public string? Remark { get; set; }
+
+ ///
+ /// 列表「内容」列:由备注 + 字段变更摘要拼装。
+ ///
+ public string Summary { get; set; } = string.Empty;
+
+ public DateTime CreateTime { get; set; }
+}
+
+public class OrderOperationLogListOutput
+{
+ public List Items { get; set; } = [];
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index a82aea9..4eaed1f 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -55,6 +55,11 @@ public class OrderOutput
///
public string? DownstreamSupplierCode { get; set; }
+ ///
+ /// 下游 API 服务商名称。
+ ///
+ public string? DownstreamSupplierName { get; set; }
+
///
/// 下游 itfx 应用 AppKey。
///
@@ -363,6 +368,16 @@ public class OrderOutput
///
public decimal? ForecastPayableAmount { get; set; }
+ ///
+ /// 订单绑定的销售产品规则发布版本。
+ ///
+ public string? SalesProductRulesVersion { get; set; }
+
+ ///
+ /// 订单绑定的销售产品价格发布版本。
+ ///
+ public string? SalesProductPriceVersion { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs
index 0f7f1fe..66875f0 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs
@@ -41,12 +41,12 @@ public class OrderParcelCreateOutput
public string? ConsigneeCountryName { get; set; }
///
- /// 同步创建的揽收单号(上门揽收成功时)。
+ /// 揽收单号(上门揽收;事件总线消费较快或同步回落时可能已有值)。
///
public string? PickupNo { get; set; }
///
- /// 同步创建的揽收单 Id。
+ /// 揽收单 Id。
///
public int? PickupOrderId { get; set; }
@@ -59,4 +59,24 @@ public class OrderParcelCreateOutput
/// 揽收单创建失败原因(运单已成功时仅作提示)。
///
public string? PickupCreateError { get; set; }
+
+ ///
+ /// 揽收单已入事件总线、后台创建中(此时尚无 PickupNo)。
+ ///
+ public bool PickupQueued { get; set; }
+
+ ///
+ /// 订单状态(创单后可能仍为草稿,预报计费异步处理中)。
+ ///
+ public string? OrderStatus { get; set; }
+
+ ///
+ /// 预报失败/处理中原因。
+ ///
+ public string? PredeclareFailReason { get; set; }
+
+ ///
+ /// 预报计费/排货已入队或后台处理中。
+ ///
+ public bool ForecastBillingQueued { get; set; }
}
diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
index 2f728b6..84a3c24 100644
--- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
+++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs
@@ -1,6 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Oms.Application.Messaging.OrderSideEffects;
+using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Services.Orders.Batch;
+using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Integrations.SmallBagTrack;
@@ -15,31 +18,42 @@ internal sealed class SmallBagTrackOrderNotifier(
try
{
using var scope = scopeFactory.CreateScope();
- var coordinator = scope.ServiceProvider.GetRequiredService();
- var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId);
- if (!published)
+ var orderRepository = scope.ServiceProvider.GetRequiredService();
+ var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
+ if (order == null)
{
- await FallbackDirectAsync(request, orderId);
+ return;
}
+
+ var publisher = scope.ServiceProvider.GetRequiredService();
+ await publisher.PublishTrackUpsertAsync(order, request, customerId);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
- "RabbitMQ track upsert publish failed, fallback to direct call. orderId={OrderId}",
+ "Track upsert publish failed, fallback via batch coordinator. 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
{
using var scope = scopeFactory.CreateScope();
- var client = scope.ServiceProvider.GetRequiredService();
- await client.UpsertAsync(request);
+ var coordinator = scope.ServiceProvider.GetRequiredService();
+ var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId);
+ if (!published)
+ {
+ var client = scope.ServiceProvider.GetRequiredService();
+ await client.UpsertAsync(request);
+ }
}
catch (Exception ex)
{
@@ -49,23 +63,29 @@ internal sealed class SmallBagTrackOrderNotifier(
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 client = scope.ServiceProvider.GetRequiredService();
- await client.AppendLifecycleTrackAsync(orderNo, trackCode, authorization);
- }
- catch (Exception ex)
- {
- logger.LogWarning(
- ex,
- "Async lifecycle track append failed. orderId={OrderId} orderNo={OrderNo} trackCode={TrackCode}",
- orderId,
- orderNo,
- trackCode);
- }
- });
+ using var scope = scopeFactory.CreateScope();
+ var publisher = scope.ServiceProvider.GetRequiredService();
+ await publisher.PublishLifecycleTrackAppendAsync(orderId, orderNo, trackCode, authorization);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "Lifecycle track append publish failed. orderId={OrderId} orderNo={OrderNo} trackCode={TrackCode}",
+ orderId,
+ orderNo,
+ trackCode);
+ }
}
}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs
new file mode 100644
index 0000000..9cba9f9
--- /dev/null
+++ b/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs
@@ -0,0 +1,22 @@
+using Oms.Application.Services.Orders.Issues;
+
+namespace Oms.Application.Messaging.OrderSideEffects;
+
+///
+/// 订单侧后置副作用发布(生命周期轨迹、自动触发等)。
+///
+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);
+}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs
new file mode 100644
index 0000000..670c52e
--- /dev/null
+++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs
@@ -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 logger)
+ : IEventHandler, ITransientDependency
+{
+ public async Task HandleAsync(
+ OrderAutoTriggerFireIntegrationEvent @event,
+ CancellationToken cancellationToken)
+ {
+ if (@event.OrderId <= 0
+ || !Enum.TryParse(@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);
+ }
+}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireIntegrationEvent.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireIntegrationEvent.cs
new file mode 100644
index 0000000..f3d04c2
--- /dev/null
+++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireIntegrationEvent.cs
@@ -0,0 +1,19 @@
+using Fake.EventBus.Distributed;
+
+namespace Oms.Application.Messaging.OrderSideEffects;
+
+///
+/// 订单自动触发(下单 / 入库 / 出库时机),走 Fake.EventBus.RabbitMQ。
+///
+[Serializable]
+public class OrderAutoTriggerFireIntegrationEvent : IntegrationEvent
+{
+ public int OrderId { get; set; }
+
+ ///
+ /// 枚举名。
+ ///
+ public string Timing { get; set; } = string.Empty;
+
+ public int OperatorUserId { get; set; }
+}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs
new file mode 100644
index 0000000..2114097
--- /dev/null
+++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs
@@ -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 logger)
+ : IEventHandler, 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);
+ }
+}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendIntegrationEvent.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendIntegrationEvent.cs
new file mode 100644
index 0000000..6144b5e
--- /dev/null
+++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendIntegrationEvent.cs
@@ -0,0 +1,18 @@
+using Fake.EventBus.Distributed;
+
+namespace Oms.Application.Messaging.OrderSideEffects;
+
+///
+/// 订单生命周期轨迹追加(如入库 A0301、出库等),走 Fake.EventBus.RabbitMQ。
+///
+[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; }
+}
diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs
new file mode 100644
index 0000000..078be98
--- /dev/null
+++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs
@@ -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;
+
+///
+/// 优先 Fake.EventBus.RabbitMQ;发布失败则同进程同步执行 Handler。
+///
+public sealed class OrderSideEffectEventPublisher(
+ IDistributedEventBus eventBus,
+ IServiceScopeFactory scopeFactory,
+ ILogger 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(
+ 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(
+ h => h.HandleAsync(@event, cancellationToken),
+ cancellationToken);
+ }
+
+ private async Task 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(
+ Func action,
+ CancellationToken cancellationToken)
+ where THandler : notnull
+ {
+ try
+ {
+ using var scope = scopeFactory.CreateScope();
+ var handler = scope.ServiceProvider.GetRequiredService();
+ await action(handler);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name);
+ }
+ }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs
new file mode 100644
index 0000000..2545a2e
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs
@@ -0,0 +1,46 @@
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Integrations.SmallBagTrack;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Application.Messaging.ParcelCreate;
+
+///
+/// 创单后置副作用:发布到 Fake.EventBus.RabbitMQ;发布失败时回落同步执行。
+///
+public interface IOrderParcelCreateEventPublisher
+{
+ ///
+ /// 轨迹订单 + 创建成功轨迹(A0101)合并为一个事件。
+ ///
+ 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);
+
+ ///
+ /// 发布揽收挂接事件。返回 true 表示已入队;false 表示已同步回落执行(或参数不足未执行)。
+ ///
+ Task PublishPickupAttachAsync(
+ OrderParcelCreateInput input,
+ int orderId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 同步执行揽收挂接(用于创单 HTTP 需要回写 PickupNo 的场景)。
+ ///
+ Task ExecutePickupAttachAsync(
+ OrderParcelCreateInput input,
+ int orderId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs
new file mode 100644
index 0000000..e265284
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs
@@ -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;
+
+///
+/// 创单后置事件发布器:优先走 Fake.EventBus.RabbitMQ,失败则同进程同步执行 Handler 逻辑。
+///
+public sealed class OrderParcelCreateEventPublisher(
+ IDistributedEventBus eventBus,
+ IServiceScopeFactory scopeFactory,
+ ILogger 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(
+ 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(
+ 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(
+ h => h.HandleAsync(@event, cancellationToken),
+ cancellationToken);
+ }
+
+ public async Task 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 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();
+ 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 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(
+ Func action,
+ CancellationToken cancellationToken)
+ where THandler : notnull
+ {
+ try
+ {
+ using var scope = scopeFactory.CreateScope();
+ var handler = scope.ServiceProvider.GetRequiredService();
+ await action(handler);
+ }
+ catch (Exception ex)
+ {
+ // 同步回落失败也不抛回创单主流程
+ logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name);
+ }
+ }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs
new file mode 100644
index 0000000..bb292d2
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs
@@ -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;
+
+///
+/// 处理创单后 9610 汇总申报订单写入事件。
+///
+public sealed class OrderParcelCustoms9610UpsertEventHandler(
+ IOrderRepository orderRepository,
+ Customs9610OrderWriter customs9610OrderWriter,
+ IUnitOfWorkManager unitOfWorkManager,
+ ILogger logger)
+ : IEventHandler, 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);
+ }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertIntegrationEvent.cs
new file mode 100644
index 0000000..ef933ef
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertIntegrationEvent.cs
@@ -0,0 +1,14 @@
+using Fake.EventBus.Distributed;
+
+namespace Oms.Application.Messaging.ParcelCreate;
+
+///
+/// 创单后置:按 9610 汇总申报写入/刷新 t_9610_order(非 9610 时 Handler 内会按规则清理 pending)。
+///
+[Serializable]
+public class OrderParcelCustoms9610UpsertIntegrationEvent : IntegrationEvent
+{
+ public int OrderId { get; set; }
+
+ public string? PlatformCode { get; set; }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs
new file mode 100644
index 0000000..8e29720
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs
@@ -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;
+
+///
+/// 处理创单后系统面单生成事件。
+///
+public sealed class OrderParcelLabelGenerateEventHandler(
+ IServiceScopeFactory scopeFactory,
+ IUnitOfWorkManager unitOfWorkManager,
+ ILogger logger)
+ : IEventHandler, ITransientDependency
+{
+ public async Task HandleAsync(
+ OrderParcelLabelGenerateIntegrationEvent @event,
+ CancellationToken cancellationToken)
+ {
+ using var unitOfWork = unitOfWorkManager.Begin();
+ using var scope = scopeFactory.CreateScope();
+ var orderService = scope.ServiceProvider.GetRequiredService();
+ 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);
+ }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateIntegrationEvent.cs
new file mode 100644
index 0000000..a7a807b
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateIntegrationEvent.cs
@@ -0,0 +1,14 @@
+using Fake.EventBus.Distributed;
+
+namespace Oms.Application.Messaging.ParcelCreate;
+
+///
+/// 创单后置:生成系统面单 PDF。
+///
+[Serializable]
+public class OrderParcelLabelGenerateIntegrationEvent : IntegrationEvent
+{
+ public int OrderId { get; set; }
+
+ public int OperatorUserId { get; set; }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs
new file mode 100644
index 0000000..488e95c
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs
@@ -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;
+
+///
+/// 处理创单后上门揽收挂接事件。
+///
+public sealed class OrderParcelPickupAttachEventHandler(
+ PickupOrderService pickupOrderService,
+ IUnitOfWorkManager unitOfWorkManager,
+ ILogger logger)
+ : IEventHandler, 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);
+ }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachIntegrationEvent.cs
new file mode 100644
index 0000000..18afd3d
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachIntegrationEvent.cs
@@ -0,0 +1,26 @@
+using Fake.EventBus.Distributed;
+
+namespace Oms.Application.Messaging.ParcelCreate;
+
+///
+/// 创单后置:上门揽收时创建并挂接揽收订单(可选再预报承运)。
+///
+[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; }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs
new file mode 100644
index 0000000..6347a3c
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs
@@ -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;
+
+///
+/// 处理「轨迹订单 + 创建成功轨迹」合并事件。
+///
+public sealed class OrderParcelTrackUpsertEventHandler(
+ IOrderRepository orderRepository,
+ ISmallBagTrackOrderClient trackClient,
+ ISmallBagTrackUpsertRequestBuilder requestBuilder,
+ IUnitOfWorkManager unitOfWorkManager,
+ ILogger logger)
+ : IEventHandler, 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 ResolveRequestAsync(
+ OrderParcelTrackUpsertIntegrationEvent @event,
+ CancellationToken cancellationToken)
+ {
+ if (!string.IsNullOrWhiteSpace(@event.PayloadJson))
+ {
+ var fromPayload = JsonConvert.DeserializeObject(@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);
+ }
+}
diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertIntegrationEvent.cs
new file mode 100644
index 0000000..214c668
--- /dev/null
+++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertIntegrationEvent.cs
@@ -0,0 +1,28 @@
+using Fake.EventBus.Distributed;
+
+namespace Oms.Application.Messaging.ParcelCreate;
+
+///
+/// 创单后置:新增轨迹订单,并在成功后追加「创建订单成功」轨迹(A0101)。
+/// 二者合并为同一集成事件,由 Handler 内 UpsertSmallBag + AppendInitialTrackOnSuccess 一次完成。
+///
+[Serializable]
+public class OrderParcelTrackUpsertIntegrationEvent : IntegrationEvent
+{
+ public int OrderId { get; set; }
+
+ public int? CustomerId { get; set; }
+
+ ///
+ /// 序列化后的 ;
+ /// 为空时 Handler 按 OrderId 重建。
+ ///
+ public string? PayloadJson { get; set; }
+
+ ///
+ /// 是否在 Upsert 成功后追加创单默认轨迹 A0101。
+ ///
+ public bool AppendInitialTrackOnSuccess { get; set; } = true;
+
+ public string? Authorization { get; set; }
+}
diff --git a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs
index 24960a7..8131be4 100644
--- a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs
+++ b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs
@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Oms.Application.Messaging;
+using Oms.Application.Messaging.OrderSideEffects;
+using Oms.Application.Messaging.ParcelCreate;
namespace Oms.Application;
@@ -11,6 +13,8 @@ public static class OrderBatchMessagingServiceExtensions
services.Configure(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName));
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
// 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管
services.AddHostedService();
return services;
diff --git a/src/Oms.Application/Services/Operations/OutboundRecordService.cs b/src/Oms.Application/Services/Operations/OutboundRecordService.cs
index 30f84ff..b64d4b7 100644
--- a/src/Oms.Application/Services/Operations/OutboundRecordService.cs
+++ b/src/Oms.Application/Services/Operations/OutboundRecordService.cs
@@ -161,18 +161,8 @@ public class OutboundRecordService : ApplicationService
var savedEntity = await OutboundRecordRepository.InsertAsync(entity);
- try
- {
- var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim());
- if (refreshed != null)
- {
- await AutoTriggerRuntimeService.FireAsync(refreshed, AutoTriggerTiming.Outbound, operatorId);
- }
- }
- catch
- {
- // 自动触发失败不影响出库主流程
- }
+ // 自动触发改走 Fake.EventBus,避免拉长出库扫码 RT
+ AutoTriggerRuntimeService.EnqueueFire(order.Id, AutoTriggerTiming.Outbound, operatorId);
return ObjectMapper.Map(savedEntity);
}
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
index 913cd7a..b5c9283 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs
@@ -26,6 +26,7 @@ public sealed class OrderBatchActionExecutor(
OrderDownstreamCredentialResolver downstreamCredentialResolver,
IItfxForwardClient itfxForwardClient,
OrderFileManager orderFileManager,
+ OrderOperationLogWriter operationLogWriter,
OrderBatchCreateActionHandler createActionHandler,
OrderBatchOrderActionHandler orderActionHandler) : IOrderBatchActionExecutor, IScopedDependency
{
@@ -49,6 +50,7 @@ public sealed class OrderBatchActionExecutor(
OrderBatchActionCodes.Print => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.GenerateLabelPdf => 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.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken),
OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken),
@@ -224,6 +226,8 @@ public sealed class OrderBatchActionExecutor(
businessMessage));
}
+ var beforeSnap = OrderOperationLogSnapshot.CaptureOrder(order);
+
if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null)
{
@@ -242,6 +246,29 @@ public sealed class OrderBatchActionExecutor(
}
}
+ var afterSnap = OrderOperationLogSnapshot.CaptureOrder(order);
+ var remarkParts = new List { $"供应商 {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(
serviceNo: order.ServiceNo,
trackNo: order.TrackNo,
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs
index b8f75fd..703bf09 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs
@@ -269,6 +269,7 @@ public static class OrderBatchJobItemFeedback
OrderBatchActionCodes.Print => "打印成功",
OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功",
OrderBatchActionCodes.InboundBilling => "入库计费/排货完成",
+ OrderBatchActionCodes.ForecastBilling => "预报计费/排货完成",
OrderBatchActionCodes.Reallocate => "重新排货成功",
OrderBatchActionCodes.MarkProblematic => "问题件创建成功",
OrderBatchActionCodes.MarkClaimed => "理赔件创建成功",
diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs
index f578a60..f363cd8 100644
--- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs
+++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs
@@ -67,6 +67,14 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto
await orderService.ProcessInboundBillingForBatchAsync(message.OrderId, cancellationToken);
return OrderBatchActionExecuteResult.Ok();
+ case OrderBatchActionCodes.ForecastBilling:
+ await orderService.ProcessForecastBillingForBatchAsync(
+ message.OrderId,
+ message.PayloadJson,
+ message.OperatorUserId,
+ cancellationToken);
+ return OrderBatchActionExecuteResult.Ok();
+
case OrderBatchActionCodes.Reallocate:
await orderService.ReallocateForBatchAsync(
message.OrderId,
diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs
index e8276d1..9b70856 100644
--- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs
+++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs
@@ -1,6 +1,6 @@
using Fake.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Oms.Application.Messaging.OrderSideEffects;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
@@ -16,13 +16,13 @@ public class AutoTriggerRuntimeService(
OrderAutoTriggerService orderAutoTriggerService,
OrderExceptionWorkflowService exceptionWorkflowService,
OrderIssueNotificationService issueNotificationService,
- IServiceScopeFactory scopeFactory,
+ IOrderSideEffectEventPublisher sideEffectEventPublisher,
ILogger logger) : IScopedDependency
{
private const string InterceptOperation = "拦截";
///
- /// 后台异步触发,避免拉长入库/出库扫码请求。
+ /// 后台异步触发(Fake.EventBus.RabbitMQ),避免拉长入库/出库/创单请求。
///
public void EnqueueFire(int orderId, AutoTriggerTiming timing, int operatorUserId)
{
@@ -31,23 +31,7 @@ public class AutoTriggerRuntimeService(
return;
}
- _ = Task.Run(async () =>
- {
- try
- {
- using var scope = scopeFactory.CreateScope();
- var runtime = scope.ServiceProvider.GetRequiredService();
- await runtime.FireByOrderIdAsync(orderId, timing, operatorUserId);
- }
- catch (Exception ex)
- {
- logger.LogWarning(
- ex,
- "异步自动触发失败 OrderId={OrderId} Timing={Timing}",
- orderId,
- timing);
- }
- });
+ _ = sideEffectEventPublisher.PublishAutoTriggerFireAsync(orderId, timing, operatorUserId);
}
public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId)
diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs
new file mode 100644
index 0000000..f37c5d4
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs
@@ -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;
+
+///
+/// 运单操作日志白名单快照与字段 label。
+///
+public static class OrderOperationLogSnapshot
+{
+ public const int SummaryMaxChangeItems = 5;
+
+ private static readonly Dictionary 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 CaptureOrder(Order order)
+ {
+ return new Dictionary(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 CaptureSender(OrderSender? sender)
+ {
+ if (sender == null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new Dictionary(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 CaptureReceiver(OrderRecipient? recipient)
+ {
+ if (recipient == null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new Dictionary(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 CaptureWeights(IEnumerable weights)
+ {
+ var map = new Dictionary(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 CaptureFeeTotals(IEnumerable 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(StringComparer.OrdinalIgnoreCase)
+ {
+ ["fee.receivableTotal"] = FormatDecimal(receivable),
+ ["fee.payableTotal"] = FormatDecimal(payable),
+ };
+ }
+
+ public static Dictionary Merge(params IReadOnlyDictionary[] parts)
+ {
+ var map = new Dictionary(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 before,
+ IReadOnlyDictionary 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;
+ }
+
+ ///
+ /// 拼装列表「内容」列:备注优先,再跟最多 N 条字段变更。
+ ///
+ public static string BuildSummary(
+ IReadOnlyList changes,
+ string? remark,
+ int maxItems = SummaryMaxChangeItems)
+ {
+ var parts = new List();
+ 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>(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";
+}
diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs
new file mode 100644
index 0000000..3a6cd6b
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs
@@ -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;
+
+///
+/// 运单操作日志写入器:字段 diff 落库;失败不影响主业务。
+///
+public class OrderOperationLogWriter : IScopedDependency
+{
+ private readonly IOrderOperationLogRepository _repository;
+ private readonly ICurrentUser _currentUser;
+ private readonly OmsRbacUserDisplayNameResolver _userDisplayNameResolver;
+ private readonly ILogger _logger;
+
+ public OrderOperationLogWriter(
+ IOrderOperationLogRepository repository,
+ ICurrentUser currentUser,
+ OmsRbacUserDisplayNameResolver userDisplayNameResolver,
+ ILogger logger)
+ {
+ _repository = repository;
+ _currentUser = currentUser;
+ _userDisplayNameResolver = userDisplayNameResolver;
+ _logger = logger;
+ }
+
+ public Task TryWriteAsync(
+ Order order,
+ string action,
+ IReadOnlyDictionary before,
+ IReadOnlyDictionary after,
+ string? remark = null)
+ => TryWriteAsync(order, action, before, after, remark, operatorId: null, operatorName: null);
+
+ public async Task TryWriteAsync(
+ Order order,
+ string action,
+ IReadOnlyDictionary before,
+ IReadOnlyDictionary 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);
+ }
+ }
+
+ ///
+ /// 无字段 diff 时写入叙事日志(打印、下游预报成功等)。
+ ///
+ 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(StringComparer.OrdinalIgnoreCase),
+ new Dictionary(StringComparer.OrdinalIgnoreCase),
+ remark,
+ operatorId,
+ operatorName,
+ operatorType,
+ source);
+
+ public async Task 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();
+ 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();
+ if (!string.IsNullOrWhiteSpace(row.ChangesJson))
+ {
+ try
+ {
+ changes = JsonConvert.DeserializeObject>(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
+ };
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index c5d6e34..40f81fa 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -165,6 +165,7 @@ public partial class OrderService
MaxTimeliness = product?.MaxTimeliness,
ChannelCode = order.ChannelCode,
DownstreamSupplierCode = order.DownstreamSupplierCode,
+ DownstreamSupplierName = order.DownstreamSupplierName,
DownstreamAppKey = order.DownstreamAppKey,
DownstreamAppId = order.DownstreamAppId,
ChannelForecastCode = order.ChannelForecastCode,
@@ -262,6 +263,12 @@ public partial class OrderService
HasInsurance = hasInsurance,
ForecastReceivableAmount = order.ForecastReceivableAmount,
ForecastPayableAmount = order.ForecastPayableAmount,
+ SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
+ ? null
+ : order.SalesProductRulesVersion.Trim(),
+ SalesProductPriceVersion = string.IsNullOrWhiteSpace(order.SalesProductPriceVersion)
+ ? null
+ : order.SalesProductPriceVersion.Trim(),
CreateTime = order.CreateTime,
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap),
UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime),
@@ -387,6 +394,7 @@ public partial class OrderService
output.ChannelCode = null;
output.ChannelName = null;
output.DownstreamSupplierCode = null;
+ output.DownstreamSupplierName = null;
output.DownstreamAppKey = null;
output.DownstreamAppId = null;
output.ChannelForecastCode = null;
diff --git a/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs
new file mode 100644
index 0000000..eefa146
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs
@@ -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();
+
+ ///
+ /// 运单操作日志列表(按时间倒序)。
+ ///
+ [HttpGet("/order/operation-log/list")]
+ public async Task GetOperationLogListAsync([FromQuery] int orderId)
+ {
+ await GetOrderOrThrowAsync(orderId);
+ return await OperationLogWriter.ListByOrderIdAsync(orderId);
+ }
+
+ private async Task> CaptureOrderAuditSnapshotAsync(
+ Order order,
+ bool includeParties = false,
+ bool includeWeights = false,
+ bool includeFees = false)
+ {
+ var parts = new List>
+ {
+ 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());
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index 616c9f0..0a98566 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@@ -28,6 +29,7 @@ public partial class OrderService
public async Task UpdateOrderDetailAsync(OrderUpdateInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true);
if (input.ClientNo != null)
{
@@ -134,6 +136,16 @@ public partial class OrderService
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();
}
@@ -149,6 +161,7 @@ public partial class OrderService
}
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法重算计费重");
@@ -224,6 +237,14 @@ public partial class OrderService
order.UpdateTime = DateTime.Now;
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();
}
@@ -234,8 +255,16 @@ public partial class OrderService
public async Task RecalculateReceivableFeesAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
EnsureReceivableMutable(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();
}
@@ -246,10 +275,141 @@ public partial class OrderService
public async Task RecalculatePayableFeesAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
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();
}
+ ///
+ /// 切换订单绑定的销售产品规则/价格发布版本(显式改契约后再重算)。
+ ///
+ [HttpPost("/order/fee/bind-snapshot-versions")]
+ public async Task 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();
+ 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();
+ }
+
+ ///
+ /// 解析绑定目标版本:CURRENT 取当前有效发布,否则按版本号校验存在。
+ ///
+ private async Task 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();
+ }
+
///
/// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。
///
@@ -566,8 +726,17 @@ public partial class OrderService
public async Task ReallocateAsync([FromBody] OrderReallocateInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId);
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();
}
@@ -580,12 +749,22 @@ public partial class OrderService
NormalizeReallocateOptions(input.Scheme, input.ServiceChannelId, out var scheme, out var channelId);
var result = new OrderBatchActionResultOutput();
var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList();
+ var remark = string.IsNullOrWhiteSpace(scheme) ? null : $"scheme={scheme}";
foreach (var orderId in orderIds)
{
try
{
var order = await GetOrderOrThrowAsync(orderId);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
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++;
}
catch (Exception ex)
@@ -609,6 +788,7 @@ public partial class OrderService
{
var order = await GetOrderOrThrowAsync(input.Id);
EnsureReceivableMutable(order);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
@@ -627,6 +807,15 @@ public partial class OrderService
order.UpdateTime = DateTime.Now;
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();
}
@@ -716,6 +905,8 @@ public partial class OrderService
throw new BusinessException("计费阶段无效");
}
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
+
var fee = new OrderFee
{
Type = type,
@@ -736,6 +927,17 @@ public partial class OrderService
order.UpdateTime = DateTime.Now;
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();
}
@@ -955,6 +1157,7 @@ public partial class OrderService
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
// 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。
+ // 锁渠道重算应付:允许渠道不在旧规则排货池内(渠道已确认,只重算买价)。
var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order);
@@ -977,7 +1180,14 @@ public partial class OrderService
billing,
chargingStage,
schemeOverride,
- forceChannelId);
+ forceChannelId,
+ allowDesignatedOutsidePool: lockCurrentChannel);
+
+ // 重新排货成功后写回规则版本,使后续重算应付与当前排货池一致;卖价版本保持契约,不随排货改写。
+ if (enforceReallocateGate)
+ {
+ order.SalesProductRulesVersion = snapshotBinding.RulesVersion;
+ }
if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
{
@@ -990,15 +1200,15 @@ public partial class OrderService
private static void EnsureReallocateAllowed(Order order)
{
- if (order.IsPaid != 0)
- {
- throw new BusinessException("订单已付款,不允许重新排货");
- }
-
- if (order.OrderStatus is not (OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment))
+ // 已付款也可排货:只重算应付与服务渠道,不改应收。
+ if (order.OrderStatus is not (
+ OrderStatusCodes.Predeclared
+ or OrderStatusCodes.PendingAllocation
+ or OrderStatusCodes.PendingPayment
+ or OrderStatusCodes.PendingShip))
{
throw new BusinessException(
- $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅待排货/待支付可重新排货");
+ $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅已预报/待排货/待支付/待发货可重新排货");
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 2fd317d..fbd528a 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -9,6 +9,8 @@ using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Labeling;
using Oms.Application.Messaging;
+using Oms.Application.Messaging.OrderSideEffects;
+using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Application.Services.Orders.Batch;
@@ -36,6 +38,8 @@ public partial class OrderService
private const int ParcelBatchMaxCount = 2000;
private const string InboundBillingProcessingReason = "入库后计费/排货处理中";
private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费";
+ private const string ForecastBillingProcessingReason = "预报计费/排货处理中";
+ private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报";
private static readonly ConcurrentDictionary InboundBillingInFlight = new();
@@ -143,10 +147,11 @@ public partial class OrderService
// 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。
if (input.SaveAsDraft)
{
- await TryUpsertCustoms9610Async(order, input.PlatformCode);
+ await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode);
return await BuildParcelCreateOutputAsync(order);
}
+ // 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。
order.IsRemote = await IsRemoteHitAsync(
product,
snapshotBinding.Rules,
@@ -154,62 +159,24 @@ public partial class OrderService
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
-
- var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
- await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
- 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.OrderStatus = OrderStatusCodes.Draft;
+ order.PredeclareFailReason = ForecastBillingProcessingReason;
+ order.AllocationPendingReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
- await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver, appendInitialTrack: true);
- await EnqueueGenerateLabelPdfAsync(order);
+ await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode);
+ await EnqueueForecastBillingAsync(
+ order,
+ input.CouponInstanceId,
+ operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0));
- try
- {
- var triggerUserId = operatorUserId ?? CurrentUser.GetId();
- await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId);
- }
- catch
- {
- // 自动触发失败不影响预报主流程
- }
-
- return await BuildParcelCreateOutputAsync(order);
+ var output = await BuildParcelCreateOutputAsync(order);
+ output.ForecastBillingQueued = string.Equals(
+ order.PredeclareFailReason,
+ ForecastBillingProcessingReason,
+ StringComparison.Ordinal);
+ return output;
}
catch (Exception ex)
{
@@ -848,7 +815,8 @@ public partial class OrderService
string chargingStage,
string? _,
string? schemeOverride = null,
- int? forceServiceChannelId = null)
+ int? forceServiceChannelId = null,
+ bool allowDesignatedOutsidePool = false)
{
var evaluation = await EvaluateAllocationAsync(
order,
@@ -858,7 +826,8 @@ public partial class OrderService
chargeableWeight,
chargingStage,
schemeOverride,
- forceServiceChannelId);
+ forceServiceChannelId,
+ allowDesignatedOutsidePool);
ApplyAllocationTraceToOrder(order, evaluation);
@@ -928,7 +897,8 @@ public partial class OrderService
decimal chargeableWeight,
string chargingStage,
string? schemeOverride,
- int? forceServiceChannelId)
+ int? forceServiceChannelId,
+ bool allowDesignatedOutsidePool = false)
{
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
@@ -985,27 +955,61 @@ public partial class OrderService
if (designated.Count == 0)
{
var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value);
- var failReason = poolRow == null
- ? "指定渠道不在该销售产品排货池"
- : "指定渠道不满足判断条件";
- if (poolRow != null
- && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
+ // 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。
+ if (allowDesignatedOutsidePool)
{
- skipped.Add(new OrderAllocationSkippedOutput
+ var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
+ x.Id == forceServiceChannelId.Value);
+ if (lockedChannel == null)
{
- ServiceChannelId = forceServiceChannelId.Value,
- ChannelCode = poolRow.ChannelCode,
- ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
- Stage = AllocationResolver.SkipStageCondition,
- MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
- Reason = failReason
- });
+ return BaseEval("订单服务渠道不存在,无法重算应付");
+ }
+
+ var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto
+ {
+ SalesProductId = product.Id,
+ ServiceChannelId = lockedChannel.Id,
+ ServiceChannelName = lockedChannel.ChannelName,
+ ProviderName = lockedChannel.ChannelName ?? lockedChannel.ChannelCode,
+ ChannelCode = lockedChannel.ChannelCode,
+ Weight = 0,
+ CustomerIds = [],
+ MatchCondition = null
+ };
+ if (poolRow == null
+ || AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment(lockedProvider))
+ {
+ await AllocationProviderSnapshotEnricher.EnrichAsync([lockedProvider]);
+ }
+
+ filtered = [lockedProvider];
}
+ else
+ {
+ var failReason = poolRow == null
+ ? "指定渠道不在该销售产品排货池"
+ : "指定渠道不满足判断条件";
+ if (poolRow != null
+ && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
+ {
+ skipped.Add(new OrderAllocationSkippedOutput
+ {
+ ServiceChannelId = forceServiceChannelId.Value,
+ ChannelCode = poolRow.ChannelCode,
+ ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
+ Stage = AllocationResolver.SkipStageCondition,
+ MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
+ Reason = failReason
+ });
+ }
- return BaseEval(failReason);
+ return BaseEval(failReason);
+ }
+ }
+ else
+ {
+ filtered = designated;
}
-
- filtered = designated;
}
if (filtered.Count == 0)
@@ -1304,7 +1308,8 @@ public partial class OrderService
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
- int? forceServiceChannelId = null) =>
+ int? forceServiceChannelId = null,
+ bool allowDesignatedOutsidePool = false) =>
ConfirmServiceChannelAndPayableAsync(
order,
product,
@@ -1314,7 +1319,8 @@ public partial class OrderService
chargingStage,
billing.PriceTierKey,
schemeOverride,
- forceServiceChannelId);
+ forceServiceChannelId,
+ allowDesignatedOutsidePool);
///
/// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。
@@ -1325,7 +1331,8 @@ public partial class OrderService
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
- int? forceServiceChannelId = null)
+ int? forceServiceChannelId = null,
+ bool allowDesignatedOutsidePool = false)
{
try
{
@@ -1335,7 +1342,8 @@ public partial class OrderService
billing,
chargingStage,
schemeOverride,
- forceServiceChannelId);
+ forceServiceChannelId,
+ allowDesignatedOutsidePool);
return true;
}
catch (BusinessException ex)
@@ -2246,8 +2254,7 @@ public partial class OrderService
}
///
- /// 草稿重新预报:按订单当前收件/件重尺/快照重跑黑名单、计重、分区与预报应收。
- /// 失败时保持草稿并写入 PredeclareFailReason。
+ /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。
///
internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null)
{
@@ -2278,63 +2285,52 @@ public partial class OrderService
throw new BusinessException("收件地址命中黑名单区域");
}
- order.IsRemote = await IsRemoteHitAsync(
- product,
- snapshotBinding.Rules,
- address.CountryCode,
- address.Province,
- address.City,
- address.ZipCode) ? (byte)1 : (byte)0;
+ var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
+ if (!weights.Any(x =>
+ string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new BusinessException("订单缺少预报件重尺");
+ }
- var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
- .OrderBy(x => x.Id)
- .ToList();
- var forecast = weights.FirstOrDefault(x =>
- string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase))
- ?? throw new BusinessException("订单缺少预报件重尺");
-
- await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
- SyncWeightRows(weights, forecast);
- await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
-
- var billing = await ResolveOrderBillingContextAsync(
- order,
- product,
- forecast.ChargeableWeight ?? 0,
- snapshotBinding);
-
- await UpsertReceivableFeesAsync(
- order,
- billing,
- OrderFeeChargingStageCodes.Forecast,
- replaceExistingReceivable: true);
-
- order.OrderStatus = OrderStatusCodes.Predeclared;
- order.PredeclareFailReason = string.Empty;
+ order.OrderStatus = OrderStatusCodes.Draft;
+ order.PredeclareFailReason = ForecastBillingProcessingReason;
order.AllocationPendingReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
- var customer = order.CustomerId.HasValue
- ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
- : null;
- await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null, appendInitialTrack: true);
- await EnqueueGenerateLabelPdfAsync(order);
+ await EnqueueForecastBillingAsync(
+ order,
+ order.CouponInstanceId,
+ operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0));
- 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();
- await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId);
- }
- catch
- {
- // 自动触发失败不影响预报主流程
+ throw CreateDraftSavedBusinessException(
+ refreshed,
+ StripDraftSavedMarker(refreshed.PredeclareFailReason));
}
}
catch (BusinessException ex)
{
+ if (string.Equals(ex.ErrorCode, OrderParcelDraftFailData.ErrorCodeValue, StringComparison.Ordinal))
+ {
+ throw;
+ }
+
var cleanReason = StripDraftSavedMarker(ex.Message);
- await MarkParcelDraftFailedAsync(order, cleanReason);
+ if (!string.Equals(cleanReason, ForecastBillingProcessingReason, StringComparison.Ordinal))
+ {
+ await MarkParcelDraftFailedAsync(order, cleanReason);
+ }
+
throw CreateDraftSavedBusinessException(order, cleanReason);
}
catch (Exception ex)
@@ -2423,7 +2419,7 @@ public partial class OrderService
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
- EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode);
+ await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.InboundTrackCode);
await EnqueueInboundBillingAsync(order);
}
finally
@@ -2605,6 +2601,270 @@ public partial class OrderService
return text[..512];
}
+ ///
+ /// 预报计费/排货异步任务(创单与草稿预报共用)。
+ ///
+ 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(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()
+ .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()
+ .CreateLogger("Oms.ForecastBilling");
+ var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
+ {
+ CouponInstanceId = couponInstanceId
+ });
+
+ try
+ {
+ var publisher = LazyServiceProvider.GetRequiredService();
+ 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)
{
var logger = LazyServiceProvider.GetRequiredService()
@@ -2778,7 +3038,7 @@ public partial class OrderService
order.UpdateTime = now;
await OrderRepository.UpdateAsync(order);
- EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.OutboundTrackCode);
+ await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.OutboundTrackCode);
}
private async Task BuildParcelCreateOutputAsync(Order order)
@@ -2836,46 +3096,56 @@ public partial class OrderService
TrackNo = order.TrackNo,
ConsigneeCountry = order.ConsigneeCountry,
ConsigneeCountryName = order.ConsigneeCountryName,
- LabelUrl = labelUrl
+ LabelUrl = labelUrl,
+ OrderStatus = order.OrderStatus,
+ PredeclareFailReason = order.PredeclareFailReason,
+ ForecastBillingQueued = string.Equals(
+ order.PredeclareFailReason,
+ ForecastBillingProcessingReason,
+ StringComparison.Ordinal)
};
}
- private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier =>
- LazyServiceProvider.GetRequiredService();
+ private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher =>
+ LazyServiceProvider.GetRequiredService();
- private async Task EnqueueGenerateLabelPdfAsync(Order order)
+ private IOrderSideEffectEventPublisher SideEffectEventPublisher =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。
+ ///
+ 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()
+ .HttpContext?
+ .Request
+ .Headers
+ .Authorization
+ .ToString();
+ if (!string.IsNullOrWhiteSpace(authorization))
{
- var publisher = LazyServiceProvider.GetRequiredService();
- if (!publisher.IsEnabled)
- {
- // 本地未开队列:同步生成,避免 Pending 悬挂
- var mapped = (await MapOrderOutputsAsync([order])).First();
- await LabelPdfGenerationService.GenerateAndPersistAsync(
- order,
- mapped,
- operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0);
- return;
- }
+ request.Authorization = authorization;
+ }
- await BatchJobCoordinator.CreateAndEnqueueAsync(
- new OrderBatchJobCreateInput
- {
- Action = OrderBatchActionCodes.GenerateLabelPdf,
- OrderIds = [order.Id],
- RequestId = $"label-pdf:{order.Id}:{Guid.NewGuid():N}"
- },
- CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0);
- }
- catch (Exception)
- {
- // 投递失败不阻断创单;打印时会补生成
- }
+ await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
+ await ParcelCreateEventPublisher.PublishLabelGenerateAsync(
+ order,
+ operatorUserId
+ ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0));
}
///
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
+ /// 入库等非创单路径仍可调用;创单路径请走 。
///
private async Task EnqueueSmallBagTrackUpsertAsync(
Order order,
@@ -2897,10 +3167,10 @@ public partial class OrderService
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))
{
@@ -2915,10 +3185,10 @@ public partial class OrderService
.Authorization
.ToString();
- SmallBagTrackOrderNotifier.EnqueueLifecycleTrackAppend(
+ await SideEffectEventPublisher.PublishLifecycleTrackAppendAsync(
+ order.Id,
order.ServiceNo,
trackCode,
- order.Id,
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 9d33c6c..5e90860 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.ObjectStorage;
@@ -11,6 +12,7 @@ using Oms.Application.Labeling;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Newtonsoft.Json;
+using Oms.Application.Messaging.ParcelCreate;
using Oms.Application.Services.Orders.Batch;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
@@ -146,6 +148,7 @@ public partial class OrderService : ApplicationService
throw new BusinessException("订单未关联客户,无法支付");
}
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var payAmount = order.PayableAmount ?? fees
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
@@ -188,6 +191,16 @@ public partial class OrderService : ApplicationService
order.IsPaid = 1;
order.OrderStatus = OrderStatusCodes.PendingShip;
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}");
}
///
@@ -345,6 +358,10 @@ public partial class OrderService : ApplicationService
var existingUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
if (!string.IsNullOrWhiteSpace(existingUrl))
{
+ await OperationLogWriter.TryWriteSimpleAsync(
+ order,
+ OrderOperationActionCodes.Print,
+ "打印面单(使用已有文件)");
return new OrderPrintOutput
{
PrintUrl = existingUrl!,
@@ -364,6 +381,10 @@ public partial class OrderService : ApplicationService
{
// 兼容回落:仍无模板时给 HTML stub,避免彻底不可打
var fallbackUrl = await CreateOrderPrintFileAsync(mapped);
+ await OperationLogWriter.TryWriteSimpleAsync(
+ order,
+ OrderOperationActionCodes.Print,
+ "打印面单(回落 HTML)");
return new OrderPrintOutput
{
PrintUrl = fallbackUrl,
@@ -372,6 +393,10 @@ public partial class OrderService : ApplicationService
};
}
+ await OperationLogWriter.TryWriteSimpleAsync(
+ order,
+ OrderOperationActionCodes.Print,
+ "打印面单");
return new OrderPrintOutput
{
PrintUrl = result.LabelUrl!,
@@ -419,6 +444,21 @@ public partial class OrderService : ApplicationService
PrintUrl = url,
LabelGenerateStatus = LabelGenerateStatusCodes.Ready
});
+ try
+ {
+ var existingOrder = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
+ if (existingOrder != null)
+ {
+ await OperationLogWriter.TryWriteSimpleAsync(
+ existingOrder,
+ OrderOperationActionCodes.Print,
+ "批量打印(使用已有文件)");
+ }
+ }
+ catch
+ {
+ // 日志失败不影响打印
+ }
}
else
{
@@ -660,21 +700,24 @@ public partial class OrderService : ApplicationService
return;
}
+ var publisher = LazyServiceProvider.GetRequiredService();
try
{
- var pickupService = LazyServiceProvider.GetRequiredService();
- var pickup = await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput
+ // 优先入 Fake.EventBus;发布失败则同步回落建揽收并回写单号。
+ var queued = await publisher.PublishPickupAttachAsync(input, output.OrderId);
+ if (queued)
{
- 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 = [output.OrderId],
- Remark = null
- });
+ output.PickupQueued = true;
+ return;
+ }
+
+ var pickup = await publisher.ExecutePickupAttachAsync(input, output.OrderId);
+ if (pickup == null)
+ {
+ output.PickupCreateError = "揽收单创建失败";
+ return;
+ }
+
output.PickupNo = pickup.PickupNo;
output.PickupOrderId = pickup.Id;
output.CarrierWaybillNo = pickup.CarrierWaybillNo;
diff --git a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
index afb1ef4..4073363 100644
--- a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
+++ b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
@@ -108,6 +108,9 @@ public class AllocationProviderSnapshotEnricher(
ServiceChannel? channelFallback = null)
{
order.DownstreamSupplierCode = NullIfEmpty(provider.SupplierCode);
+ order.DownstreamSupplierName = NullIfEmpty(provider.ApiServiceProviderName)
+ ?? NullIfEmpty(provider.GroupName)
+ ?? NullIfEmpty(provider.ProviderName);
order.DownstreamAppKey = NullIfEmpty(provider.AppKey);
order.DownstreamAppId = provider.AppId;
order.ChannelForecastCode = NullIfEmpty(provider.ForecastCode)
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs
index da96b64..e6250a6 100644
--- a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs
@@ -33,6 +33,8 @@ public static class OrderBatchActionCodes
public const string Print = "print";
public const string GenerateLabelPdf = "generate-label-pdf";
public const string InboundBilling = "inbound-billing";
+ /// 创单/草稿预报:预报应收计费 + 排货应付。
+ public const string ForecastBilling = "forecast-billing";
public const string Reallocate = "reallocate";
public const string Export = "export";
public const string ParcelCreate = "parcel-create";
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs
new file mode 100644
index 0000000..d6c482b
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs
@@ -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
+ };
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index 3aea651..2f2b77e 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -29,6 +29,12 @@ public class Order : OmsAuditedAggregateRoot
///
public string? DownstreamSupplierCode { get; set; }
+ ///
+ /// 下游 API 服务商名称(itfx service provider name)。
+ ///
+ [SugarColumn(Length = 128, IsNullable = true)]
+ public string? DownstreamSupplierName { get; set; }
+
///
/// 下游 itfx 应用 AppKey。
///
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs
new file mode 100644
index 0000000..703354b
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs
@@ -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; }
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs
new file mode 100644
index 0000000..b1321bf
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs
@@ -0,0 +1,8 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Domain.OrdersAggregate.Repositories;
+
+public interface IOrderOperationLogRepository : IRepository
+{
+}
diff --git a/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs
new file mode 100644
index 0000000..c020ea9
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs
@@ -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,
+ IOrderOperationLogRepository,
+ IScopedDependency
+{
+}