Merge remote-tracking branch 'prod/test' into test
This commit is contained in:
commit
6f56edc6a4
25
db.sql
25
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 '产品编码',
|
||||
|
||||
213
sql/drop_all_unique_indexes.sql
Normal file
213
sql/drop_all_unique_indexes.sql
Normal file
@ -0,0 +1,213 @@
|
||||
-- =============================================================================
|
||||
-- drop_all_unique_indexes.sql
|
||||
-- 去掉当前库所有非 PRIMARY 唯一索引的「唯一性」(改为同名普通 INDEX)
|
||||
-- 流程:备份并删除外键 → UNIQUE 改 KEY → 恢复外键
|
||||
-- 解决 Error 1553:Cannot drop index ... needed in a foreign key constraint
|
||||
-- 可重复执行;唯一性请在业务代码中校验。
|
||||
-- 请在 Navicat / DBeaver / MySQL Workbench 中整段执行(需要 DELIMITER)。
|
||||
-- =============================================================================
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- ---------- 预览:将处理的唯一索引 ----------
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
INDEX_NAME,
|
||||
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND NON_UNIQUE = 0
|
||||
AND INDEX_NAME <> 'PRIMARY'
|
||||
GROUP BY TABLE_NAME, INDEX_NAME
|
||||
ORDER BY TABLE_NAME, INDEX_NAME;
|
||||
|
||||
-- ---------- 预览:库中物理外键(会临时删除再重建)----------
|
||||
SELECT
|
||||
rc.CONSTRAINT_NAME,
|
||||
rc.TABLE_NAME,
|
||||
rc.REFERENCED_TABLE_NAME,
|
||||
rc.UPDATE_RULE,
|
||||
rc.DELETE_RULE
|
||||
FROM information_schema.REFERENTIAL_CONSTRAINTS rc
|
||||
WHERE rc.CONSTRAINT_SCHEMA = @db
|
||||
ORDER BY rc.TABLE_NAME, rc.CONSTRAINT_NAME;
|
||||
|
||||
DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE `sp_drop_all_unique_indexes`()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE v_fk_name VARCHAR(128);
|
||||
DECLARE v_fk_table VARCHAR(128);
|
||||
DECLARE v_ref_table VARCHAR(128);
|
||||
DECLARE v_update VARCHAR(16);
|
||||
DECLARE v_delete VARCHAR(16);
|
||||
DECLARE v_cols TEXT;
|
||||
DECLARE v_ref_cols TEXT;
|
||||
DECLARE v_table VARCHAR(128);
|
||||
DECLARE v_index VARCHAR(128);
|
||||
DECLARE v_index_cols TEXT;
|
||||
DECLARE v_sql TEXT;
|
||||
|
||||
-- 1) 备份当前库全部外键定义
|
||||
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`;
|
||||
CREATE TEMPORARY TABLE `_tmp_fk_backup` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
`constraint_name` VARCHAR(128) NOT NULL,
|
||||
`table_name` VARCHAR(128) NOT NULL,
|
||||
`column_names` TEXT NOT NULL,
|
||||
`ref_table_name` VARCHAR(128) NOT NULL,
|
||||
`ref_column_names` TEXT NOT NULL,
|
||||
`update_rule` VARCHAR(16) NOT NULL,
|
||||
`delete_rule` VARCHAR(16) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO `_tmp_fk_backup` (
|
||||
`constraint_name`, `table_name`, `column_names`,
|
||||
`ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule`
|
||||
)
|
||||
SELECT
|
||||
kcu.CONSTRAINT_NAME,
|
||||
kcu.TABLE_NAME,
|
||||
GROUP_CONCAT(CONCAT('`', kcu.COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','),
|
||||
kcu.REFERENCED_TABLE_NAME,
|
||||
GROUP_CONCAT(CONCAT('`', kcu.REFERENCED_COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','),
|
||||
rc.UPDATE_RULE,
|
||||
rc.DELETE_RULE
|
||||
FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
|
||||
ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
||||
AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
||||
AND rc.TABLE_NAME = kcu.TABLE_NAME
|
||||
WHERE kcu.TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
||||
GROUP BY
|
||||
kcu.CONSTRAINT_NAME,
|
||||
kcu.TABLE_NAME,
|
||||
kcu.REFERENCED_TABLE_NAME,
|
||||
rc.UPDATE_RULE,
|
||||
rc.DELETE_RULE;
|
||||
|
||||
-- 2) 删除全部外键(解除对唯一索引的占用)
|
||||
BEGIN
|
||||
DECLARE cur_fk_drop CURSOR FOR
|
||||
SELECT `constraint_name`, `table_name` FROM `_tmp_fk_backup` ORDER BY `id`;
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
SET done = 0;
|
||||
OPEN cur_fk_drop;
|
||||
fk_drop_loop: LOOP
|
||||
FETCH cur_fk_drop INTO v_fk_name, v_fk_table;
|
||||
IF done = 1 THEN
|
||||
LEAVE fk_drop_loop;
|
||||
END IF;
|
||||
SET v_sql = CONCAT('ALTER TABLE `', v_fk_table, '` DROP FOREIGN KEY `', v_fk_name, '`');
|
||||
SET @ddl := v_sql;
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE cur_fk_drop;
|
||||
END;
|
||||
|
||||
-- 3) 唯一索引 → 同名普通 INDEX(去掉唯一性,保留列与查询能力,便于外键重建)
|
||||
BEGIN
|
||||
DECLARE cur_uk CURSOR FOR
|
||||
SELECT
|
||||
s.TABLE_NAME,
|
||||
s.INDEX_NAME,
|
||||
GROUP_CONCAT(CONCAT('`', s.COLUMN_NAME, '`') ORDER BY s.SEQ_IN_INDEX SEPARATOR ',')
|
||||
FROM information_schema.STATISTICS s
|
||||
WHERE s.TABLE_SCHEMA = DATABASE()
|
||||
AND s.NON_UNIQUE = 0
|
||||
AND s.INDEX_NAME <> 'PRIMARY'
|
||||
GROUP BY s.TABLE_NAME, s.INDEX_NAME
|
||||
ORDER BY s.TABLE_NAME, s.INDEX_NAME;
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
SET done = 0;
|
||||
OPEN cur_uk;
|
||||
uk_loop: LOOP
|
||||
FETCH cur_uk INTO v_table, v_index, v_index_cols;
|
||||
IF done = 1 THEN
|
||||
LEAVE uk_loop;
|
||||
END IF;
|
||||
SET v_sql = CONCAT(
|
||||
'ALTER TABLE `', v_table, '` DROP INDEX `', v_index,
|
||||
'`, ADD INDEX `', v_index, '` (', v_index_cols, ')'
|
||||
);
|
||||
SET @ddl := v_sql;
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE cur_uk;
|
||||
END;
|
||||
|
||||
-- 4) 按备份重建外键
|
||||
-- 若原外键引用的是「非主键唯一键」,去掉唯一性后 InnoDB 可能拒绝重建,将跳过并写入警告表
|
||||
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`;
|
||||
CREATE TEMPORARY TABLE `_tmp_fk_restore_fail` (
|
||||
`constraint_name` VARCHAR(128) NOT NULL,
|
||||
`table_name` VARCHAR(128) NOT NULL,
|
||||
`error_msg` TEXT
|
||||
);
|
||||
|
||||
BEGIN
|
||||
DECLARE cur_fk_add CURSOR FOR
|
||||
SELECT
|
||||
`constraint_name`, `table_name`, `column_names`,
|
||||
`ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule`
|
||||
FROM `_tmp_fk_backup`
|
||||
ORDER BY `id`;
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
GET DIAGNOSTICS CONDITION 1 @fk_err = MESSAGE_TEXT;
|
||||
INSERT INTO `_tmp_fk_restore_fail` (`constraint_name`, `table_name`, `error_msg`)
|
||||
VALUES (v_fk_name, v_fk_table, @fk_err);
|
||||
END;
|
||||
SET done = 0;
|
||||
OPEN cur_fk_add;
|
||||
fk_add_loop: LOOP
|
||||
FETCH cur_fk_add INTO
|
||||
v_fk_name, v_fk_table, v_cols, v_ref_table, v_ref_cols, v_update, v_delete;
|
||||
IF done = 1 THEN
|
||||
LEAVE fk_add_loop;
|
||||
END IF;
|
||||
SET v_sql = CONCAT(
|
||||
'ALTER TABLE `', v_fk_table,
|
||||
'` ADD CONSTRAINT `', v_fk_name,
|
||||
'` FOREIGN KEY (', v_cols,
|
||||
') REFERENCES `', v_ref_table, '` (', v_ref_cols,
|
||||
') ON UPDATE ', v_update,
|
||||
' ON DELETE ', v_delete
|
||||
);
|
||||
SET @ddl := v_sql;
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
CLOSE cur_fk_add;
|
||||
END;
|
||||
|
||||
-- 输出重建失败的外键(通常因引用侧不再是 UNIQUE/PRIMARY)
|
||||
SELECT * FROM `_tmp_fk_restore_fail`;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`;
|
||||
DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL `sp_drop_all_unique_indexes`();
|
||||
DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`;
|
||||
|
||||
-- ---------- 校验:唯一索引应无结果(PRIMARY 除外)----------
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
INDEX_NAME,
|
||||
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND NON_UNIQUE = 0
|
||||
AND INDEX_NAME <> 'PRIMARY'
|
||||
GROUP BY TABLE_NAME, INDEX_NAME
|
||||
ORDER BY TABLE_NAME, INDEX_NAME;
|
||||
105
sql/migrate_20261009_order_warehouse.sql
Normal file
105
sql/migrate_20261009_order_warehouse.sql
Normal file
@ -0,0 +1,105 @@
|
||||
-- =============================================================================
|
||||
-- migrate_20261009_order_warehouse.sql
|
||||
-- 汇总:
|
||||
-- 1) t_order.DownstreamSupplierName(下游服务商名称)
|
||||
-- 2) t_order_operation_log(运单操作日志)
|
||||
-- 3) t_warehouse_address.Sort / Remark(仓库地址排序与备注)
|
||||
-- 可重复执行。
|
||||
-- =============================================================================
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1) 订单:下游服务商名称
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_order'
|
||||
AND COLUMN_NAME = 'DownstreamSupplierName'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`',
|
||||
'SELECT ''t_order.DownstreamSupplierName already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2) 运单操作日志表
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @tbl := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_order_operation_log'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@tbl = 0,
|
||||
'CREATE TABLE `t_order_operation_log` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
|
||||
`OrderId` INT NOT NULL COMMENT ''运单Id'',
|
||||
`ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'',
|
||||
`Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'',
|
||||
`ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'',
|
||||
`OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'',
|
||||
`OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'',
|
||||
`OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'',
|
||||
`Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'',
|
||||
`ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'',
|
||||
`Remark` VARCHAR(512) NULL COMMENT ''补充说明'',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
|
||||
`UpdateUserId` INT NULL COMMENT ''更新人'',
|
||||
`UpdateTime` DATETIME NULL COMMENT ''更新时间'',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `idx_ool_order_time` (`OrderId`, `CreateTime`),
|
||||
KEY `idx_ool_service_no` (`ServiceNo`)
|
||||
) COMMENT=''运单操作日志''',
|
||||
'SELECT ''t_order_operation_log already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3) 仓库地址:展示排序 + 备注
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_warehouse_address'
|
||||
AND COLUMN_NAME = 'Sort'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_warehouse_address`
|
||||
ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`',
|
||||
'SELECT ''t_warehouse_address.Sort already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @col := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_warehouse_address'
|
||||
AND COLUMN_NAME = 'Remark'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col = 0,
|
||||
'ALTER TABLE `t_warehouse_address`
|
||||
ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`',
|
||||
'SELECT ''t_warehouse_address.Remark already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
22
sql/migrate_order_downstream_supplier_name.sql
Normal file
22
sql/migrate_order_downstream_supplier_name.sql
Normal file
@ -0,0 +1,22 @@
|
||||
-- 订单增加下游服务商名称(排货确认时写入 itfx 服务商名 / 分组名)。
|
||||
-- 可重复执行。
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_order'
|
||||
AND COLUMN_NAME = 'DownstreamSupplierName'
|
||||
);
|
||||
|
||||
SET @sql := IF(
|
||||
@exists = 0,
|
||||
'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`',
|
||||
'SELECT ''t_order.DownstreamSupplierName already exists'' AS info'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
42
sql/migrate_order_operation_log.sql
Normal file
42
sql/migrate_order_operation_log.sql
Normal file
@ -0,0 +1,42 @@
|
||||
-- =============================================================================
|
||||
-- migrate_order_operation_log.sql
|
||||
-- 运单操作日志表(业务动作 + 字段级 before/after diff)
|
||||
-- 可重复执行
|
||||
-- =============================================================================
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @tbl := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = @db
|
||||
AND TABLE_NAME = 't_order_operation_log'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@tbl = 0,
|
||||
'CREATE TABLE `t_order_operation_log` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
|
||||
`OrderId` INT NOT NULL COMMENT ''运单Id'',
|
||||
`ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'',
|
||||
`Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate/bindSnapshotVersions/updateAdditionalServices/addFee/pay/preDeclare/downstreamForward/print'',
|
||||
`ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'',
|
||||
`OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'',
|
||||
`OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'',
|
||||
`OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'',
|
||||
`Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'',
|
||||
`ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'',
|
||||
`Remark` VARCHAR(512) NULL COMMENT ''补充说明'',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
|
||||
`UpdateUserId` INT NULL COMMENT ''更新人'',
|
||||
`UpdateTime` DATETIME NULL COMMENT ''更新时间'',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `idx_ool_order_time` (`OrderId`, `CreateTime`),
|
||||
KEY `idx_ool_service_no` (`ServiceNo`)
|
||||
) COMMENT=''运单操作日志''',
|
||||
'SELECT ''t_order_operation_log already exists'' AS info'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 切换订单绑定的销售产品规则/价格发布版本。
|
||||
/// </summary>
|
||||
public class OrderBindSnapshotVersionsInput
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标规则版本;空表示不改。特殊值 <c>CURRENT</c> 表示绑定当前有效规则发布。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? RulesVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标价格版本;空表示不改。特殊值 <c>CURRENT</c> 表示绑定当前有效价格发布。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? PriceVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 绑定后是否立即重算应收(已支付会拒绝)。
|
||||
/// </summary>
|
||||
public bool RecalculateReceivable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 绑定后是否立即重算应付(锁定当前渠道)。
|
||||
/// </summary>
|
||||
public bool RecalculatePayable { get; set; }
|
||||
}
|
||||
49
src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs
Normal file
49
src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs
Normal file
@ -0,0 +1,49 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
public class OrderOperationLogChangeItem
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public string? Before { get; set; }
|
||||
|
||||
public string? After { get; set; }
|
||||
}
|
||||
|
||||
public class OrderOperationLogOutput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public string? ServiceNo { get; set; }
|
||||
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
public string ActionName { get; set; } = string.Empty;
|
||||
|
||||
public int OperatorId { get; set; }
|
||||
|
||||
public string? OperatorName { get; set; }
|
||||
|
||||
public string OperatorType { get; set; } = string.Empty;
|
||||
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
public List<OrderOperationLogChangeItem> Changes { get; set; } = [];
|
||||
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 列表「内容」列:由备注 + 字段变更摘要拼装。
|
||||
/// </summary>
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
}
|
||||
|
||||
public class OrderOperationLogListOutput
|
||||
{
|
||||
public List<OrderOperationLogOutput> Items { get; set; } = [];
|
||||
}
|
||||
@ -55,6 +55,11 @@ public class OrderOutput
|
||||
/// </summary>
|
||||
public string? DownstreamSupplierCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下游 API 服务商名称。
|
||||
/// </summary>
|
||||
public string? DownstreamSupplierName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下游 itfx 应用 AppKey。
|
||||
/// </summary>
|
||||
@ -363,6 +368,16 @@ public class OrderOutput
|
||||
/// </summary>
|
||||
public decimal? ForecastPayableAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单绑定的销售产品规则发布版本。
|
||||
/// </summary>
|
||||
public string? SalesProductRulesVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单绑定的销售产品价格发布版本。
|
||||
/// </summary>
|
||||
public string? SalesProductPriceVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@ -41,12 +41,12 @@ public class OrderParcelCreateOutput
|
||||
public string? ConsigneeCountryName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同步创建的揽收单号(上门揽收成功时)。
|
||||
/// 揽收单号(上门揽收;事件总线消费较快或同步回落时可能已有值)。
|
||||
/// </summary>
|
||||
public string? PickupNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同步创建的揽收单 Id。
|
||||
/// 揽收单 Id。
|
||||
/// </summary>
|
||||
public int? PickupOrderId { get; set; }
|
||||
|
||||
@ -59,4 +59,24 @@ public class OrderParcelCreateOutput
|
||||
/// 揽收单创建失败原因(运单已成功时仅作提示)。
|
||||
/// </summary>
|
||||
public string? PickupCreateError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 揽收单已入事件总线、后台创建中(此时尚无 PickupNo)。
|
||||
/// </summary>
|
||||
public bool PickupQueued { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态(创单后可能仍为草稿,预报计费异步处理中)。
|
||||
/// </summary>
|
||||
public string? OrderStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预报失败/处理中原因。
|
||||
/// </summary>
|
||||
public string? PredeclareFailReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预报计费/排货已入队或后台处理中。
|
||||
/// </summary>
|
||||
public bool ForecastBillingQueued { get; set; }
|
||||
}
|
||||
|
||||
@ -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<OrderBatchJobCoordinator>();
|
||||
var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId);
|
||||
if (!published)
|
||||
var orderRepository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
|
||||
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
|
||||
if (order == null)
|
||||
{
|
||||
await FallbackDirectAsync(request, orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
var publisher = scope.ServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
|
||||
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<ISmallBagTrackOrderClient>();
|
||||
await client.UpsertAsync(request);
|
||||
var coordinator = scope.ServiceProvider.GetRequiredService<OrderBatchJobCoordinator>();
|
||||
var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId);
|
||||
if (!published)
|
||||
{
|
||||
var client = scope.ServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();
|
||||
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<ISmallBagTrackOrderClient>();
|
||||
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<IOrderSideEffectEventPublisher>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
using Oms.Application.Services.Orders.Issues;
|
||||
|
||||
namespace Oms.Application.Messaging.OrderSideEffects;
|
||||
|
||||
/// <summary>
|
||||
/// 订单侧后置副作用发布(生命周期轨迹、自动触发等)。
|
||||
/// </summary>
|
||||
public interface IOrderSideEffectEventPublisher
|
||||
{
|
||||
Task PublishLifecycleTrackAppendAsync(
|
||||
int orderId,
|
||||
string orderNo,
|
||||
string trackCode,
|
||||
string? authorization = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task PublishAutoTriggerFireAsync(
|
||||
int orderId,
|
||||
AutoTriggerTiming timing,
|
||||
int operatorUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Services.Orders.Issues;
|
||||
|
||||
namespace Oms.Application.Messaging.OrderSideEffects;
|
||||
|
||||
public sealed class OrderAutoTriggerFireEventHandler(
|
||||
AutoTriggerRuntimeService autoTriggerRuntimeService,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<OrderAutoTriggerFireEventHandler> logger)
|
||||
: IEventHandler<OrderAutoTriggerFireIntegrationEvent>, ITransientDependency
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
OrderAutoTriggerFireIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (@event.OrderId <= 0
|
||||
|| !Enum.TryParse<AutoTriggerTiming>(@event.Timing, ignoreCase: true, out var timing))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"AutoTrigger fire skipped invalid payload OrderId={OrderId} Timing={Timing}",
|
||||
@event.OrderId,
|
||||
@event.Timing);
|
||||
return;
|
||||
}
|
||||
|
||||
using var unitOfWork = unitOfWorkManager.Begin();
|
||||
try
|
||||
{
|
||||
await autoTriggerRuntimeService.FireByOrderIdAsync(
|
||||
@event.OrderId,
|
||||
timing,
|
||||
@event.OperatorUserId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"AutoTrigger fire failed OrderId={OrderId} Timing={Timing}",
|
||||
@event.OrderId,
|
||||
timing);
|
||||
}
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
|
||||
namespace Oms.Application.Messaging.OrderSideEffects;
|
||||
|
||||
/// <summary>
|
||||
/// 订单自动触发(下单 / 入库 / 出库时机),走 Fake.EventBus.RabbitMQ。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class OrderAutoTriggerFireIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Services.Orders.Issues.AutoTriggerTiming"/> 枚举名。
|
||||
/// </summary>
|
||||
public string Timing { get; set; } = string.Empty;
|
||||
|
||||
public int OperatorUserId { get; set; }
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Integrations.SmallBagTrack;
|
||||
|
||||
namespace Oms.Application.Messaging.OrderSideEffects;
|
||||
|
||||
public sealed class OrderLifecycleTrackAppendEventHandler(
|
||||
ISmallBagTrackOrderClient trackClient,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<OrderLifecycleTrackAppendEventHandler> logger)
|
||||
: IEventHandler<OrderLifecycleTrackAppendIntegrationEvent>, ITransientDependency
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
OrderLifecycleTrackAppendIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(@event.OrderNo) || string.IsNullOrWhiteSpace(@event.TrackCode))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Lifecycle track append skipped empty OrderNo/TrackCode OrderId={OrderId}",
|
||||
@event.OrderId);
|
||||
return;
|
||||
}
|
||||
|
||||
using var unitOfWork = unitOfWorkManager.Begin();
|
||||
try
|
||||
{
|
||||
await trackClient.AppendLifecycleTrackAsync(
|
||||
@event.OrderNo,
|
||||
@event.TrackCode,
|
||||
@event.Authorization,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 轨迹失败不反复打满队列;与原 Task.Run 吞异常策略一致
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Lifecycle track append failed OrderId={OrderId} OrderNo={OrderNo} TrackCode={TrackCode}",
|
||||
@event.OrderId,
|
||||
@event.OrderNo,
|
||||
@event.TrackCode);
|
||||
}
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
|
||||
namespace Oms.Application.Messaging.OrderSideEffects;
|
||||
|
||||
/// <summary>
|
||||
/// 订单生命周期轨迹追加(如入库 A0301、出库等),走 Fake.EventBus.RabbitMQ。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class OrderLifecycleTrackAppendIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
public string TrackCode { get; set; } = string.Empty;
|
||||
|
||||
public string? Authorization { get; set; }
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Services.Orders.Issues;
|
||||
|
||||
namespace Oms.Application.Messaging.OrderSideEffects;
|
||||
|
||||
/// <summary>
|
||||
/// 优先 Fake.EventBus.RabbitMQ;发布失败则同进程同步执行 Handler。
|
||||
/// </summary>
|
||||
public sealed class OrderSideEffectEventPublisher(
|
||||
IDistributedEventBus eventBus,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OrderSideEffectEventPublisher> logger) : IOrderSideEffectEventPublisher
|
||||
{
|
||||
public async Task PublishLifecycleTrackAppendAsync(
|
||||
int orderId,
|
||||
string orderNo,
|
||||
string trackCode,
|
||||
string? authorization = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var @event = new OrderLifecycleTrackAppendIntegrationEvent
|
||||
{
|
||||
OrderId = orderId,
|
||||
OrderNo = orderNo.Trim(),
|
||||
TrackCode = trackCode.Trim(),
|
||||
Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim()
|
||||
};
|
||||
|
||||
if (await TryPublishAsync(@event, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteInScopeAsync<OrderLifecycleTrackAppendEventHandler>(
|
||||
h => h.HandleAsync(@event, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PublishAutoTriggerFireAsync(
|
||||
int orderId,
|
||||
AutoTriggerTiming timing,
|
||||
int operatorUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (orderId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var @event = new OrderAutoTriggerFireIntegrationEvent
|
||||
{
|
||||
OrderId = orderId,
|
||||
Timing = timing.ToString(),
|
||||
OperatorUserId = operatorUserId
|
||||
};
|
||||
|
||||
if (await TryPublishAsync(@event, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteInScopeAsync<OrderAutoTriggerFireEventHandler>(
|
||||
h => h.HandleAsync(@event, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> TryPublishAsync(IntegrationEvent @event, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(@event, cancellationToken);
|
||||
logger.LogDebug(
|
||||
"Published side-effect event {EventName} Id={EventId}",
|
||||
@event.GetType().Name,
|
||||
@event.Id);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Publish side-effect event {EventName} failed, fallback to sync. EventId={EventId}",
|
||||
@event.GetType().Name,
|
||||
@event.Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteInScopeAsync<THandler>(
|
||||
Func<THandler, Task> action,
|
||||
CancellationToken cancellationToken)
|
||||
where THandler : notnull
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var handler = scope.ServiceProvider.GetRequiredService<THandler>();
|
||||
await action(handler);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Integrations.SmallBagTrack;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置副作用:发布到 Fake.EventBus.RabbitMQ;发布失败时回落同步执行。
|
||||
/// </summary>
|
||||
public interface IOrderParcelCreateEventPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 轨迹订单 + 创建成功轨迹(A0101)合并为一个事件。
|
||||
/// </summary>
|
||||
Task PublishTrackUpsertAsync(
|
||||
Order order,
|
||||
SmallBagTrackUpsertRequest request,
|
||||
int? customerId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task PublishLabelGenerateAsync(
|
||||
Order order,
|
||||
int operatorUserId = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task PublishCustoms9610UpsertAsync(
|
||||
Order order,
|
||||
string? platformCode = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 发布揽收挂接事件。返回 true 表示已入队;false 表示已同步回落执行(或参数不足未执行)。
|
||||
/// </summary>
|
||||
Task<bool> PublishPickupAttachAsync(
|
||||
OrderParcelCreateInput input,
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 同步执行揽收挂接(用于创单 HTTP 需要回写 PickupNo 的场景)。
|
||||
/// </summary>
|
||||
Task<PickupOrderOutput?> ExecutePickupAttachAsync(
|
||||
OrderParcelCreateInput input,
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,198 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Integrations.SmallBagTrack;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置事件发布器:优先走 Fake.EventBus.RabbitMQ,失败则同进程同步执行 Handler 逻辑。
|
||||
/// </summary>
|
||||
public sealed class OrderParcelCreateEventPublisher(
|
||||
IDistributedEventBus eventBus,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OrderParcelCreateEventPublisher> logger) : IOrderParcelCreateEventPublisher
|
||||
{
|
||||
public async Task PublishTrackUpsertAsync(
|
||||
Order order,
|
||||
SmallBagTrackUpsertRequest request,
|
||||
int? customerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var @event = new OrderParcelTrackUpsertIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
CustomerId = customerId,
|
||||
PayloadJson = JsonConvert.SerializeObject(request),
|
||||
AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
|
||||
Authorization = request.Authorization
|
||||
};
|
||||
|
||||
if (await TryPublishAsync(@event, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteInScopeAsync<OrderParcelTrackUpsertEventHandler>(
|
||||
h => h.HandleAsync(@event, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PublishLabelGenerateAsync(
|
||||
Order order,
|
||||
int operatorUserId = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var @event = new OrderParcelLabelGenerateIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
OperatorUserId = operatorUserId
|
||||
};
|
||||
|
||||
if (await TryPublishAsync(@event, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteInScopeAsync<OrderParcelLabelGenerateEventHandler>(
|
||||
h => h.HandleAsync(@event, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PublishCustoms9610UpsertAsync(
|
||||
Order order,
|
||||
string? platformCode = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var @event = new OrderParcelCustoms9610UpsertIntegrationEvent
|
||||
{
|
||||
OrderId = order.Id,
|
||||
PlatformCode = platformCode
|
||||
};
|
||||
|
||||
if (await TryPublishAsync(@event, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteInScopeAsync<OrderParcelCustoms9610UpsertEventHandler>(
|
||||
h => h.HandleAsync(@event, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> PublishPickupAttachAsync(
|
||||
OrderParcelCreateInput input,
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var @event = BuildPickupEvent(input, orderId);
|
||||
if (@event == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 揽收回落由调用方 ExecutePickupAttachAsync 同步执行,避免重复建单且便于回写单号。
|
||||
return await TryPublishAsync(@event, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PickupOrderOutput?> ExecutePickupAttachAsync(
|
||||
OrderParcelCreateInput input,
|
||||
int orderId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (orderId <= 0
|
||||
|| input.SenderAddressId is not > 0
|
||||
|| input.ExpectPickupTime is null
|
||||
|| input.ExpectPickupTime == default
|
||||
|| input.WarehouseId is not > 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var pickupService = scope.ServiceProvider.GetRequiredService<PickupOrderService>();
|
||||
return await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput
|
||||
{
|
||||
CustomerId = input.CustomerId,
|
||||
SenderAddressId = input.SenderAddressId.Value,
|
||||
Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection),
|
||||
WarehouseId = input.WarehouseId.Value,
|
||||
CarrierCode = input.CarrierCode,
|
||||
CarrierService = input.CarrierService,
|
||||
ExpectPickupTime = input.ExpectPickupTime.Value,
|
||||
OrderIds = [orderId],
|
||||
Remark = null
|
||||
});
|
||||
}
|
||||
|
||||
private static OrderParcelPickupAttachIntegrationEvent? BuildPickupEvent(
|
||||
OrderParcelCreateInput input,
|
||||
int orderId)
|
||||
{
|
||||
if (orderId <= 0
|
||||
|| input.SenderAddressId is not > 0
|
||||
|| input.ExpectPickupTime is null
|
||||
|| input.ExpectPickupTime == default
|
||||
|| input.WarehouseId is not > 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OrderParcelPickupAttachIntegrationEvent
|
||||
{
|
||||
OrderId = orderId,
|
||||
CustomerId = input.CustomerId,
|
||||
SenderAddressId = input.SenderAddressId.Value,
|
||||
WarehouseId = input.WarehouseId.Value,
|
||||
PickupDirection = input.PickupDirection,
|
||||
CarrierCode = input.CarrierCode,
|
||||
CarrierService = input.CarrierService,
|
||||
ExpectPickupTime = input.ExpectPickupTime.Value
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> TryPublishAsync(IntegrationEvent @event, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventBus.PublishAsync(@event, cancellationToken);
|
||||
logger.LogDebug(
|
||||
"Published parcel-create event {EventName} Id={EventId}",
|
||||
@event.GetType().Name,
|
||||
@event.Id);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Publish parcel-create event {EventName} failed, fallback to sync. EventId={EventId}",
|
||||
@event.GetType().Name,
|
||||
@event.Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteInScopeAsync<THandler>(
|
||||
Func<THandler, Task> action,
|
||||
CancellationToken cancellationToken)
|
||||
where THandler : notnull
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var handler = scope.ServiceProvider.GetRequiredService<THandler>();
|
||||
await action(handler);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 同步回落失败也不抛回创单主流程
|
||||
logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 处理创单后 9610 汇总申报订单写入事件。
|
||||
/// </summary>
|
||||
public sealed class OrderParcelCustoms9610UpsertEventHandler(
|
||||
IOrderRepository orderRepository,
|
||||
Customs9610OrderWriter customs9610OrderWriter,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<OrderParcelCustoms9610UpsertEventHandler> logger)
|
||||
: IEventHandler<OrderParcelCustoms9610UpsertIntegrationEvent>, ITransientDependency
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
OrderParcelCustoms9610UpsertIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var unitOfWork = unitOfWorkManager.Begin();
|
||||
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken);
|
||||
if (order == null)
|
||||
{
|
||||
logger.LogWarning("9610 upsert skipped, order missing OrderId={OrderId}", @event.OrderId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await customs9610OrderWriter.UpsertFromOrderAsync(order, @event.PlatformCode, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 与原同步路径一致:9610 失败不抛到总线重试风暴
|
||||
logger.LogWarning(ex, "9610 upsert failed OrderId={OrderId}", @event.OrderId);
|
||||
}
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置:按 9610 汇总申报写入/刷新 t_9610_order(非 9610 时 Handler 内会按规则清理 pending)。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class OrderParcelCustoms9610UpsertIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public string? PlatformCode { get; set; }
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Services.Orders;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 处理创单后系统面单生成事件。
|
||||
/// </summary>
|
||||
public sealed class OrderParcelLabelGenerateEventHandler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<OrderParcelLabelGenerateEventHandler> logger)
|
||||
: IEventHandler<OrderParcelLabelGenerateIntegrationEvent>, ITransientDependency
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
OrderParcelLabelGenerateIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var unitOfWork = unitOfWorkManager.Begin();
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
|
||||
var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
// 面单失败不阻断创单主链路;记录后正常 Ack,避免反复重试打满队列
|
||||
logger.LogWarning(
|
||||
"Label generate failed OrderId={OrderId} Error={Error}",
|
||||
@event.OrderId,
|
||||
result.Error);
|
||||
}
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置:生成系统面单 PDF。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class OrderParcelLabelGenerateIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public int OperatorUserId { get; set; }
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Services.Orders;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 处理创单后上门揽收挂接事件。
|
||||
/// </summary>
|
||||
public sealed class OrderParcelPickupAttachEventHandler(
|
||||
PickupOrderService pickupOrderService,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<OrderParcelPickupAttachEventHandler> logger)
|
||||
: IEventHandler<OrderParcelPickupAttachIntegrationEvent>, ITransientDependency
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
OrderParcelPickupAttachIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var unitOfWork = unitOfWorkManager.Begin();
|
||||
try
|
||||
{
|
||||
await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput
|
||||
{
|
||||
CustomerId = @event.CustomerId,
|
||||
SenderAddressId = @event.SenderAddressId,
|
||||
Direction = PickupDirectionCodes.NormalizeOrDefault(@event.PickupDirection),
|
||||
WarehouseId = @event.WarehouseId,
|
||||
CarrierCode = @event.CarrierCode,
|
||||
CarrierService = @event.CarrierService,
|
||||
ExpectPickupTime = @event.ExpectPickupTime,
|
||||
OrderIds = [@event.OrderId],
|
||||
Remark = null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 与原同步路径一致:揽收失败不回滚物流单,仅记日志(列表可补建)
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Pickup attach failed OrderId={OrderId}",
|
||||
@event.OrderId);
|
||||
}
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置:上门揽收时创建并挂接揽收订单(可选再预报承运)。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class OrderParcelPickupAttachIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public int SenderAddressId { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
|
||||
public string? PickupDirection { get; set; }
|
||||
|
||||
public string? CarrierCode { get; set; }
|
||||
|
||||
public string? CarrierService { get; set; }
|
||||
|
||||
public DateTime ExpectPickupTime { get; set; }
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
using Fake.Application;
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.EventBus;
|
||||
using Fake.UnitOfWork;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Oms.Application.Integrations.SmallBagTrack;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 处理「轨迹订单 + 创建成功轨迹」合并事件。
|
||||
/// </summary>
|
||||
public sealed class OrderParcelTrackUpsertEventHandler(
|
||||
IOrderRepository orderRepository,
|
||||
ISmallBagTrackOrderClient trackClient,
|
||||
ISmallBagTrackUpsertRequestBuilder requestBuilder,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
ILogger<OrderParcelTrackUpsertEventHandler> logger)
|
||||
: IEventHandler<OrderParcelTrackUpsertIntegrationEvent>, ITransientDependency
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
OrderParcelTrackUpsertIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var unitOfWork = unitOfWorkManager.Begin();
|
||||
|
||||
var request = await ResolveRequestAsync(@event, cancellationToken);
|
||||
request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess;
|
||||
if (string.IsNullOrWhiteSpace(request.Authorization)
|
||||
&& !string.IsNullOrWhiteSpace(@event.Authorization))
|
||||
{
|
||||
request.Authorization = @event.Authorization.Trim();
|
||||
}
|
||||
|
||||
var result = await trackClient.UpsertDebugAsync(request, cancellationToken);
|
||||
if (result.Skipped)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Track upsert skipped OrderId={OrderId} Reason={Reason}",
|
||||
@event.OrderId,
|
||||
result.SkipReason);
|
||||
}
|
||||
else if (!result.Success)
|
||||
{
|
||||
throw new BusinessException(
|
||||
$"轨迹订单创建失败: HTTP {result.StatusCode}");
|
||||
}
|
||||
|
||||
await unitOfWork.CompleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SmallBagTrackUpsertRequest> ResolveRequestAsync(
|
||||
OrderParcelTrackUpsertIntegrationEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(@event.PayloadJson))
|
||||
{
|
||||
var fromPayload = JsonConvert.DeserializeObject<SmallBagTrackUpsertRequest>(@event.PayloadJson);
|
||||
if (fromPayload != null && !string.IsNullOrWhiteSpace(fromPayload.OrderNo))
|
||||
{
|
||||
return fromPayload;
|
||||
}
|
||||
}
|
||||
|
||||
var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken)
|
||||
?? throw new BusinessException("订单不存在");
|
||||
if (string.IsNullOrWhiteSpace(order.ServiceNo))
|
||||
{
|
||||
throw new BusinessException("订单运单号为空,无法同步轨迹");
|
||||
}
|
||||
|
||||
return await requestBuilder.BuildAsync(order, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
using Fake.EventBus.Distributed;
|
||||
|
||||
namespace Oms.Application.Messaging.ParcelCreate;
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置:新增轨迹订单,并在成功后追加「创建订单成功」轨迹(A0101)。
|
||||
/// 二者合并为同一集成事件,由 Handler 内 UpsertSmallBag + AppendInitialTrackOnSuccess 一次完成。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class OrderParcelTrackUpsertIntegrationEvent : IntegrationEvent
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 序列化后的 <see cref="Integrations.SmallBagTrack.SmallBagTrackUpsertRequest"/>;
|
||||
/// 为空时 Handler 按 OrderId 重建。
|
||||
/// </summary>
|
||||
public string? PayloadJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否在 Upsert 成功后追加创单默认轨迹 A0101。
|
||||
/// </summary>
|
||||
public bool AppendInitialTrackOnSuccess { get; set; } = true;
|
||||
|
||||
public string? Authorization { get; set; }
|
||||
}
|
||||
@ -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<OrderBatchRabbitMqOptions>(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName));
|
||||
services.AddSingleton<IOrderBatchJobPublisher, OrderBatchJobPublisher>();
|
||||
services.AddSingleton<IOrderBatchQueueInspector, OrderBatchQueueInspector>();
|
||||
services.AddSingleton<IOrderParcelCreateEventPublisher, OrderParcelCreateEventPublisher>();
|
||||
services.AddSingleton<IOrderSideEffectEventPublisher, OrderSideEffectEventPublisher>();
|
||||
// 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管
|
||||
services.AddHostedService<OrderBatchDlxMonitorHostedService>();
|
||||
return services;
|
||||
|
||||
@ -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<OutboundRecord, OutboundRecordOutput>(savedEntity);
|
||||
}
|
||||
|
||||
@ -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<string> { $"供应商 {creds.SupplierCode}" };
|
||||
if (!string.IsNullOrWhiteSpace(order.DownstreamServiceNo))
|
||||
{
|
||||
remarkParts.Add($"服务商单号 {order.DownstreamServiceNo}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(order.TrackNo))
|
||||
{
|
||||
remarkParts.Add($"跟踪号 {order.TrackNo}");
|
||||
}
|
||||
|
||||
await operationLogWriter.TryWriteAsync(
|
||||
order,
|
||||
OrderOperationActionCodes.DownstreamForward,
|
||||
beforeSnap,
|
||||
afterSnap,
|
||||
string.Join(";", remarkParts),
|
||||
message.OperatorUserId > 0 ? message.OperatorUserId : null,
|
||||
operatorName: null,
|
||||
operatorType: "staff",
|
||||
source: "job");
|
||||
|
||||
return OrderBatchActionExecuteResult.Ok(
|
||||
serviceNo: order.ServiceNo,
|
||||
trackNo: order.TrackNo,
|
||||
|
||||
@ -269,6 +269,7 @@ public static class OrderBatchJobItemFeedback
|
||||
OrderBatchActionCodes.Print => "打印成功",
|
||||
OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功",
|
||||
OrderBatchActionCodes.InboundBilling => "入库计费/排货完成",
|
||||
OrderBatchActionCodes.ForecastBilling => "预报计费/排货完成",
|
||||
OrderBatchActionCodes.Reallocate => "重新排货成功",
|
||||
OrderBatchActionCodes.MarkProblematic => "问题件创建成功",
|
||||
OrderBatchActionCodes.MarkClaimed => "理赔件创建成功",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<AutoTriggerRuntimeService> logger) : IScopedDependency
|
||||
{
|
||||
private const string InterceptOperation = "拦截";
|
||||
|
||||
/// <summary>
|
||||
/// 后台异步触发,避免拉长入库/出库扫码请求。
|
||||
/// 后台异步触发(Fake.EventBus.RabbitMQ),避免拉长入库/出库/创单请求。
|
||||
/// </summary>
|
||||
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<AutoTriggerRuntimeService>();
|
||||
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)
|
||||
|
||||
340
src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs
Normal file
340
src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs
Normal file
@ -0,0 +1,340 @@
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 运单操作日志白名单快照与字段 label。
|
||||
/// </summary>
|
||||
public static class OrderOperationLogSnapshot
|
||||
{
|
||||
public const int SummaryMaxChangeItems = 5;
|
||||
|
||||
private static readonly Dictionary<string, string> Labels = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["orderStatus"] = "订单状态",
|
||||
["clientNo"] = "客户单号",
|
||||
["trackNo"] = "跟踪号",
|
||||
["channelCode"] = "服务渠道",
|
||||
["productCode"] = "销售产品",
|
||||
["warehouseId"] = "仓库",
|
||||
["cargoAttribute"] = "货物属性",
|
||||
["isCod"] = "是否COD",
|
||||
["codCurrency"] = "COD币种",
|
||||
["codSum"] = "COD金额",
|
||||
["declaredCurrency"] = "申报币种",
|
||||
["declaredSum"] = "申报金额",
|
||||
["quantity"] = "包裹数量",
|
||||
["receivableSubtotal"] = "应收小计",
|
||||
["payableAmount"] = "应付金额",
|
||||
["downstreamServiceNo"] = "服务商单号",
|
||||
["allocationScheme"] = "排货方案",
|
||||
["salesProductRulesVersion"] = "规则版本",
|
||||
["salesProductPriceVersion"] = "价格版本",
|
||||
["isPaid"] = "是否已支付",
|
||||
["additionalServices"] = "附加服务",
|
||||
["sender.name"] = "发件人姓名",
|
||||
["sender.phone"] = "发件人电话",
|
||||
["sender.country"] = "发件国家",
|
||||
["sender.province"] = "发件省/州",
|
||||
["sender.city"] = "发件城市",
|
||||
["sender.zipCode"] = "发件邮编",
|
||||
["sender.address1"] = "发件地址1",
|
||||
["sender.address2"] = "发件地址2",
|
||||
["receiver.name"] = "收件人姓名",
|
||||
["receiver.phone"] = "收件人电话",
|
||||
["receiver.country"] = "收件国家",
|
||||
["receiver.province"] = "收件省/州",
|
||||
["receiver.city"] = "收件城市",
|
||||
["receiver.zipCode"] = "收件邮编",
|
||||
["receiver.address1"] = "收件地址1",
|
||||
["receiver.address2"] = "收件地址2",
|
||||
["fee.receivableTotal"] = "应收合计",
|
||||
["fee.payableTotal"] = "应付合计",
|
||||
};
|
||||
|
||||
public static Dictionary<string, string?> CaptureOrder(Order order)
|
||||
{
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["orderStatus"] = Norm(order.OrderStatus),
|
||||
["clientNo"] = Norm(order.ClientNo),
|
||||
["trackNo"] = Norm(order.TrackNo),
|
||||
["channelCode"] = Norm(order.ChannelCode),
|
||||
["productCode"] = Norm(order.ProductCode),
|
||||
["warehouseId"] = FormatInt(order.WarehouseId),
|
||||
["cargoAttribute"] = Norm(order.CargoAttribute),
|
||||
["isCod"] = FormatByte(order.IsCod),
|
||||
["codCurrency"] = Norm(order.CodCurrency),
|
||||
["codSum"] = FormatDecimal(order.CodSum),
|
||||
["declaredCurrency"] = Norm(order.DeclaredCurrency),
|
||||
["declaredSum"] = FormatDecimal(order.DeclaredSum),
|
||||
["quantity"] = FormatInt(order.Quantity),
|
||||
["receivableSubtotal"] = FormatDecimal(order.ReceivableSubtotal),
|
||||
["payableAmount"] = FormatDecimal(order.PayableAmount),
|
||||
["downstreamServiceNo"] = Norm(order.DownstreamServiceNo),
|
||||
["allocationScheme"] = Norm(order.AllocationScheme),
|
||||
["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion),
|
||||
["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion),
|
||||
["isPaid"] = FormatByte(order.IsPaid),
|
||||
["additionalServices"] = FormatAdditionalServices(order.AdditionalServices),
|
||||
};
|
||||
}
|
||||
|
||||
public static Dictionary<string, string?> CaptureSender(OrderSender? sender)
|
||||
{
|
||||
if (sender == null)
|
||||
{
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sender.name"] = Norm(sender.SenderName),
|
||||
["sender.phone"] = Norm(sender.Phone),
|
||||
["sender.country"] = FormatInt(sender.Country),
|
||||
["sender.province"] = Norm(sender.Province),
|
||||
["sender.city"] = Norm(sender.City),
|
||||
["sender.zipCode"] = Norm(sender.ZipCode),
|
||||
["sender.address1"] = Norm(sender.StreetAddress1),
|
||||
["sender.address2"] = Norm(sender.StreetAddress2),
|
||||
};
|
||||
}
|
||||
|
||||
public static Dictionary<string, string?> CaptureReceiver(OrderRecipient? recipient)
|
||||
{
|
||||
if (recipient == null)
|
||||
{
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["receiver.name"] = Norm(recipient.RecipientsName),
|
||||
["receiver.phone"] = Norm(recipient.Phone),
|
||||
["receiver.country"] = FormatInt(recipient.Country),
|
||||
["receiver.province"] = Norm(recipient.Province),
|
||||
["receiver.city"] = Norm(recipient.City),
|
||||
["receiver.zipCode"] = Norm(recipient.ZipCode),
|
||||
["receiver.address1"] = Norm(recipient.StreetAddress1),
|
||||
["receiver.address2"] = Norm(recipient.StreetAddress2),
|
||||
};
|
||||
}
|
||||
|
||||
public static Dictionary<string, string?> CaptureWeights(IEnumerable<OrderWeight> weights)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var row in weights)
|
||||
{
|
||||
var type = string.IsNullOrWhiteSpace(row.Type) ? "unknown" : row.Type.Trim().ToLowerInvariant();
|
||||
var prefix = $"weight.{type}";
|
||||
map[$"{prefix}.actualWeight"] = FormatDecimal(row.ActualWeight);
|
||||
map[$"{prefix}.length"] = FormatDecimal(row.Length);
|
||||
map[$"{prefix}.width"] = FormatDecimal(row.Width);
|
||||
map[$"{prefix}.height"] = FormatDecimal(row.Height);
|
||||
map[$"{prefix}.chargeableWeight"] = FormatDecimal(row.ChargeableWeight);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
public static Dictionary<string, string?> CaptureFeeTotals(IEnumerable<OrderFee> fees)
|
||||
{
|
||||
var list = fees.ToList();
|
||||
var receivable = list
|
||||
.Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Receivable, StringComparison.OrdinalIgnoreCase))
|
||||
.Sum(x => x.Amount);
|
||||
var payable = list
|
||||
.Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Payable, StringComparison.OrdinalIgnoreCase))
|
||||
.Sum(x => x.Amount);
|
||||
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["fee.receivableTotal"] = FormatDecimal(receivable),
|
||||
["fee.payableTotal"] = FormatDecimal(payable),
|
||||
};
|
||||
}
|
||||
|
||||
public static Dictionary<string, string?> Merge(params IReadOnlyDictionary<string, string?>[] parts)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
foreach (var (key, value) in part)
|
||||
{
|
||||
map[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
public static List<(string Path, string Label, string? Before, string? After)> Diff(
|
||||
IReadOnlyDictionary<string, string?> before,
|
||||
IReadOnlyDictionary<string, string?> after)
|
||||
{
|
||||
var keys = before.Keys.Union(after.Keys, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
|
||||
var changes = new List<(string, string, string?, string?)>();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
before.TryGetValue(key, out var b);
|
||||
after.TryGetValue(key, out var a);
|
||||
b = Norm(b);
|
||||
a = Norm(a);
|
||||
if (string.Equals(b, a, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
changes.Add((key, ResolveLabel(key), b, a));
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
public static string ResolveLabel(string path)
|
||||
{
|
||||
if (Labels.TryGetValue(path, out var label))
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
if (path.StartsWith("weight.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = path.Split('.');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var typeName = parts[1] switch
|
||||
{
|
||||
"forecast" => "预报",
|
||||
"receive" => "收货",
|
||||
"ship" => "发货",
|
||||
"downstream" => "对账",
|
||||
_ => parts[1]
|
||||
};
|
||||
var fieldName = parts[2] switch
|
||||
{
|
||||
"actualWeight" => "实重",
|
||||
"length" => "长",
|
||||
"width" => "宽",
|
||||
"height" => "高",
|
||||
"chargeableWeight" => "计费重",
|
||||
_ => parts[2]
|
||||
};
|
||||
return $"{typeName}{fieldName}";
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拼装列表「内容」列:备注优先,再跟最多 N 条字段变更。
|
||||
/// </summary>
|
||||
public static string BuildSummary(
|
||||
IReadOnlyList<OrderOperationLogChangeView> changes,
|
||||
string? remark,
|
||||
int maxItems = SummaryMaxChangeItems)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(remark))
|
||||
{
|
||||
parts.Add(remark.Trim());
|
||||
}
|
||||
|
||||
if (changes.Count > 0)
|
||||
{
|
||||
var take = Math.Max(0, maxItems);
|
||||
foreach (var change in changes.Take(take))
|
||||
{
|
||||
var label = string.IsNullOrWhiteSpace(change.Label) ? change.Path : change.Label;
|
||||
parts.Add($"{label}:{DisplayValue(change.Path, change.Before)}→{DisplayValue(change.Path, change.After)}");
|
||||
}
|
||||
|
||||
if (changes.Count > take)
|
||||
{
|
||||
parts.Add($"等{changes.Count}项变更");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? "—" : string.Join(";", parts);
|
||||
}
|
||||
|
||||
public readonly record struct OrderOperationLogChangeView(
|
||||
string Path,
|
||||
string Label,
|
||||
string? Before,
|
||||
string? After);
|
||||
|
||||
private static string DisplayValue(string path, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "空";
|
||||
}
|
||||
|
||||
if (string.Equals(path, "isPaid", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(path, "isCod", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value == "1" ? "是" : "否";
|
||||
}
|
||||
|
||||
if (string.Equals(path, "orderStatus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return OmsDocHelper.GetOrderStatusName(value) ?? value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string? FormatAdditionalServices(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var list = JsonConvert.DeserializeObject<List<string>>(json) ?? [];
|
||||
var codes = list
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return codes.Count == 0 ? null : string.Join("、", codes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Norm(json);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Norm(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? FormatDecimal(decimal? value)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Value.ToString("0.####", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string? FormatInt(int? value) => value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : null;
|
||||
|
||||
private static string FormatByte(byte value) => value == 0 ? "0" : "1";
|
||||
}
|
||||
193
src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs
Normal file
193
src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs
Normal file
@ -0,0 +1,193 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.Security.Claims;
|
||||
using Fake.Users;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.OrdersAggregate.Consts;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 运单操作日志写入器:字段 diff 落库;失败不影响主业务。
|
||||
/// </summary>
|
||||
public class OrderOperationLogWriter : IScopedDependency
|
||||
{
|
||||
private readonly IOrderOperationLogRepository _repository;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly OmsRbacUserDisplayNameResolver _userDisplayNameResolver;
|
||||
private readonly ILogger<OrderOperationLogWriter> _logger;
|
||||
|
||||
public OrderOperationLogWriter(
|
||||
IOrderOperationLogRepository repository,
|
||||
ICurrentUser currentUser,
|
||||
OmsRbacUserDisplayNameResolver userDisplayNameResolver,
|
||||
ILogger<OrderOperationLogWriter> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_currentUser = currentUser;
|
||||
_userDisplayNameResolver = userDisplayNameResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task TryWriteAsync(
|
||||
Order order,
|
||||
string action,
|
||||
IReadOnlyDictionary<string, string?> before,
|
||||
IReadOnlyDictionary<string, string?> after,
|
||||
string? remark = null)
|
||||
=> TryWriteAsync(order, action, before, after, remark, operatorId: null, operatorName: null);
|
||||
|
||||
public async Task TryWriteAsync(
|
||||
Order order,
|
||||
string action,
|
||||
IReadOnlyDictionary<string, string?> before,
|
||||
IReadOnlyDictionary<string, string?> after,
|
||||
string? remark,
|
||||
int? operatorId,
|
||||
string? operatorName,
|
||||
string operatorType = "staff",
|
||||
string source = "pc")
|
||||
{
|
||||
try
|
||||
{
|
||||
var changes = OrderOperationLogSnapshot.Diff(before, after);
|
||||
var (resolvedId, resolvedName) = await ResolveOperatorAsync(operatorId, operatorName);
|
||||
|
||||
var entity = new OrderOperationLog
|
||||
{
|
||||
OrderId = order.Id,
|
||||
ServiceNo = order.ServiceNo,
|
||||
Action = action,
|
||||
ActionName = OrderOperationActionCodes.GetActionName(action),
|
||||
OperatorId = resolvedId,
|
||||
OperatorName = resolvedName,
|
||||
OperatorType = string.IsNullOrWhiteSpace(operatorType) ? "staff" : operatorType.Trim(),
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "pc" : source.Trim(),
|
||||
ChangesJson = JsonConvert.SerializeObject(
|
||||
changes.Select(x => new OrderOperationLogChangeItem
|
||||
{
|
||||
Path = x.Path,
|
||||
Label = x.Label,
|
||||
Before = x.Before,
|
||||
After = x.After
|
||||
}).ToList()),
|
||||
Remark = string.IsNullOrWhiteSpace(remark) ? null : remark.Trim(),
|
||||
CreateUserId = resolvedId,
|
||||
CreateTime = DateTime.Now
|
||||
};
|
||||
|
||||
await _repository.InsertAsync(entity);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"写入运单操作日志失败 OrderId={OrderId} Action={Action}",
|
||||
order.Id,
|
||||
action);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 无字段 diff 时写入叙事日志(打印、下游预报成功等)。
|
||||
/// </summary>
|
||||
public Task TryWriteSimpleAsync(
|
||||
Order order,
|
||||
string action,
|
||||
string? remark,
|
||||
int? operatorId = null,
|
||||
string? operatorName = null,
|
||||
string operatorType = "staff",
|
||||
string source = "pc")
|
||||
=> TryWriteAsync(
|
||||
order,
|
||||
action,
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
remark,
|
||||
operatorId,
|
||||
operatorName,
|
||||
operatorType,
|
||||
source);
|
||||
|
||||
public async Task<OrderOperationLogListOutput> ListByOrderIdAsync(int orderId)
|
||||
{
|
||||
var rows = await _repository.GetListAsync(x => x.OrderId == orderId);
|
||||
var items = rows
|
||||
.OrderByDescending(x => x.CreateTime)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Select(MapOutput)
|
||||
.ToList();
|
||||
return new OrderOperationLogListOutput { Items = items };
|
||||
}
|
||||
|
||||
private async Task<(int OperatorId, string? OperatorName)> ResolveOperatorAsync(
|
||||
int? operatorId,
|
||||
string? operatorName)
|
||||
{
|
||||
if (operatorId is > 0)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(operatorName)
|
||||
? await _userDisplayNameResolver.ResolveAsync(operatorId.Value)
|
||||
: operatorName.Trim();
|
||||
return (operatorId.Value, name);
|
||||
}
|
||||
|
||||
if (_currentUser.IsAuthenticated)
|
||||
{
|
||||
var id = _currentUser.GetId<int>();
|
||||
var name = string.IsNullOrWhiteSpace(operatorName)
|
||||
? await _userDisplayNameResolver.ResolveAsync(id)
|
||||
: operatorName.Trim();
|
||||
return (id, name);
|
||||
}
|
||||
|
||||
return (0, string.IsNullOrWhiteSpace(operatorName) ? null : operatorName.Trim());
|
||||
}
|
||||
|
||||
private static OrderOperationLogOutput MapOutput(OrderOperationLog row)
|
||||
{
|
||||
var changes = new List<OrderOperationLogChangeItem>();
|
||||
if (!string.IsNullOrWhiteSpace(row.ChangesJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
changes = JsonConvert.DeserializeObject<List<OrderOperationLogChangeItem>>(row.ChangesJson)
|
||||
?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
changes = [];
|
||||
}
|
||||
}
|
||||
|
||||
var views = changes
|
||||
.Select(x => new OrderOperationLogSnapshot.OrderOperationLogChangeView(
|
||||
x.Path,
|
||||
x.Label,
|
||||
x.Before,
|
||||
x.After))
|
||||
.ToList();
|
||||
|
||||
return new OrderOperationLogOutput
|
||||
{
|
||||
Id = row.Id,
|
||||
OrderId = row.OrderId,
|
||||
ServiceNo = row.ServiceNo,
|
||||
Action = row.Action,
|
||||
ActionName = row.ActionName,
|
||||
OperatorId = row.OperatorId,
|
||||
OperatorName = row.OperatorName,
|
||||
OperatorType = row.OperatorType,
|
||||
Source = row.Source,
|
||||
Changes = changes,
|
||||
Remark = row.Remark,
|
||||
Summary = OrderOperationLogSnapshot.BuildSummary(views, row.Remark),
|
||||
CreateTime = row.CreateTime
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
public partial class OrderService
|
||||
{
|
||||
private OrderOperationLogWriter OperationLogWriter =>
|
||||
LazyServiceProvider.GetRequiredService<OrderOperationLogWriter>();
|
||||
|
||||
/// <summary>
|
||||
/// 运单操作日志列表(按时间倒序)。
|
||||
/// </summary>
|
||||
[HttpGet("/order/operation-log/list")]
|
||||
public async Task<OrderOperationLogListOutput> GetOperationLogListAsync([FromQuery] int orderId)
|
||||
{
|
||||
await GetOrderOrThrowAsync(orderId);
|
||||
return await OperationLogWriter.ListByOrderIdAsync(orderId);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string?>> CaptureOrderAuditSnapshotAsync(
|
||||
Order order,
|
||||
bool includeParties = false,
|
||||
bool includeWeights = false,
|
||||
bool includeFees = false)
|
||||
{
|
||||
var parts = new List<IReadOnlyDictionary<string, string?>>
|
||||
{
|
||||
OrderOperationLogSnapshot.CaptureOrder(order)
|
||||
};
|
||||
|
||||
if (includeParties)
|
||||
{
|
||||
var senders = await OrderRepository.GetSendersByOrderIdsAsync([order.Id]);
|
||||
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
||||
parts.Add(OrderOperationLogSnapshot.CaptureSender(senders.FirstOrDefault()));
|
||||
parts.Add(OrderOperationLogSnapshot.CaptureReceiver(recipients.FirstOrDefault()));
|
||||
}
|
||||
|
||||
if (includeWeights)
|
||||
{
|
||||
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
|
||||
parts.Add(OrderOperationLogSnapshot.CaptureWeights(weights));
|
||||
}
|
||||
|
||||
if (includeFees)
|
||||
{
|
||||
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||||
parts.Add(OrderOperationLogSnapshot.CaptureFeeTotals(fees));
|
||||
}
|
||||
|
||||
return OrderOperationLogSnapshot.Merge(parts.ToArray());
|
||||
}
|
||||
}
|
||||
@ -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<OrderOutput> 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<OrderOutput> 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<OrderOutput> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 切换订单绑定的销售产品规则/价格发布版本(显式改契约后再重算)。
|
||||
/// </summary>
|
||||
[HttpPost("/order/fee/bind-snapshot-versions")]
|
||||
public async Task<OrderOutput> BindSnapshotVersionsAsync([FromBody] OrderBindSnapshotVersionsInput input)
|
||||
{
|
||||
var order = await GetOrderOrThrowAsync(input.Id);
|
||||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
||||
{
|
||||
throw new BusinessException("订单未绑定销售产品,无法切换版本");
|
||||
}
|
||||
|
||||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||||
if (product == null)
|
||||
{
|
||||
throw new BusinessException("销售产品不存在");
|
||||
}
|
||||
|
||||
var wantRules = !string.IsNullOrWhiteSpace(input.RulesVersion);
|
||||
var wantPrice = !string.IsNullOrWhiteSpace(input.PriceVersion);
|
||||
if (!wantRules && !wantPrice)
|
||||
{
|
||||
throw new BusinessException("请至少指定规则版本或价格版本");
|
||||
}
|
||||
|
||||
var remarkParts = new List<string>();
|
||||
if (wantRules)
|
||||
{
|
||||
var resolved = await ResolveBindTargetVersionAsync(
|
||||
product,
|
||||
RulesOperatorType,
|
||||
input.RulesVersion!.Trim(),
|
||||
"规则");
|
||||
if (!string.Equals(order.SalesProductRulesVersion, resolved, StringComparison.Ordinal))
|
||||
{
|
||||
remarkParts.Add($"规则版本 {order.SalesProductRulesVersion ?? "-"}→{resolved}");
|
||||
order.SalesProductRulesVersion = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
if (wantPrice)
|
||||
{
|
||||
var resolved = await ResolveBindTargetVersionAsync(
|
||||
product,
|
||||
PriceOperatorType,
|
||||
input.PriceVersion!.Trim(),
|
||||
"价格");
|
||||
if (!string.Equals(order.SalesProductPriceVersion, resolved, StringComparison.Ordinal))
|
||||
{
|
||||
remarkParts.Add($"价格版本 {order.SalesProductPriceVersion ?? "-"}→{resolved}");
|
||||
order.SalesProductPriceVersion = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
if (remarkParts.Count == 0 && !input.RecalculateReceivable && !input.RecalculatePayable)
|
||||
{
|
||||
throw new BusinessException("目标版本与当前绑定相同,且未勾选重算");
|
||||
}
|
||||
|
||||
order.UpdateTime = DateTime.Now;
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
|
||||
if (input.RecalculateReceivable)
|
||||
{
|
||||
EnsureReceivableMutable(order);
|
||||
await RecalculateReceivableCoreAsync(order);
|
||||
remarkParts.Add("并重算应收");
|
||||
}
|
||||
|
||||
if (input.RecalculatePayable)
|
||||
{
|
||||
await RecalculatePayableCoreAsync(order);
|
||||
remarkParts.Add("并重算应付");
|
||||
}
|
||||
|
||||
order = await GetOrderOrThrowAsync(order.Id);
|
||||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||||
await OperationLogWriter.TryWriteAsync(
|
||||
order,
|
||||
OrderOperationActionCodes.BindSnapshotVersions,
|
||||
beforeSnap,
|
||||
afterSnap,
|
||||
remarkParts.Count == 0 ? null : string.Join(';', remarkParts));
|
||||
return (await MapOrderOutputsAsync([order])).First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析绑定目标版本:CURRENT 取当前有效发布,否则按版本号校验存在。
|
||||
/// </summary>
|
||||
private async Task<string> ResolveBindTargetVersionAsync(
|
||||
SalesProduct product,
|
||||
string operatorType,
|
||||
string versionOrCurrent,
|
||||
string label)
|
||||
{
|
||||
if (string.Equals(versionOrCurrent, "CURRENT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var effective = await SalesProductRepository.GetEffectivePublishRecordAsync(
|
||||
product.Id,
|
||||
operatorType,
|
||||
DateTime.Now);
|
||||
if (effective == null || string.IsNullOrWhiteSpace(effective.Version))
|
||||
{
|
||||
throw new BusinessException($"未命中当前有效{label}发布版本");
|
||||
}
|
||||
|
||||
return effective.Version.Trim();
|
||||
}
|
||||
|
||||
var record = await SalesProductRepository.GetPublishRecordByVersionAsync(
|
||||
product.Id,
|
||||
operatorType,
|
||||
versionOrCurrent);
|
||||
if (record == null || string.IsNullOrWhiteSpace(record.Version))
|
||||
{
|
||||
throw new BusinessException($"未找到{label}发布版本:{versionOrCurrent}");
|
||||
}
|
||||
|
||||
return record.Version.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。
|
||||
/// </summary>
|
||||
@ -566,8 +726,17 @@ public partial class OrderService
|
||||
public async Task<OrderOutput> 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)}」,仅已预报/待排货/待支付/待发货可重新排货");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<int, byte> 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<int>() : 0));
|
||||
|
||||
try
|
||||
{
|
||||
var triggerUserId = operatorUserId ?? CurrentUser.GetId<int>();
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
|
||||
@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 草稿重新预报:按订单当前收件/件重尺/快照重跑黑名单、计重、分区与预报应收。
|
||||
/// 失败时保持草稿并写入 PredeclareFailReason。
|
||||
/// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。
|
||||
/// </summary>
|
||||
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<int>() : 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<int>();
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报计费/排货异步任务(创单与草稿预报共用)。
|
||||
/// </summary>
|
||||
internal async Task ProcessForecastBillingForBatchAsync(
|
||||
int orderId,
|
||||
string? payloadJson = null,
|
||||
int operatorUserId = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
|
||||
if (order == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 已预报成功则跳过(幂等)
|
||||
if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrWhiteSpace(order.PredeclareFailReason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
|
||||
|
||||
int? couponInstanceId = order.CouponInstanceId;
|
||||
if (!string.IsNullOrWhiteSpace(payloadJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = JsonConvert.DeserializeObject<ForecastBillingJobPayload>(payloadJson);
|
||||
if (payload?.CouponInstanceId is > 0)
|
||||
{
|
||||
couponInstanceId = payload.CouponInstanceId;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore bad payload
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(order, "订单未绑定销售产品,无法预报");
|
||||
return;
|
||||
}
|
||||
|
||||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||||
if (product == null || product.IsActive != EnableStatus.Enabled)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(order, "销售产品不存在或未启用");
|
||||
return;
|
||||
}
|
||||
|
||||
ProductSnapshotBinding snapshotBinding;
|
||||
OrderBillingAddress address;
|
||||
try
|
||||
{
|
||||
snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||||
address = await ResolveOrderBillingAddressAsync(order);
|
||||
order.IsRemote = await IsRemoteHitAsync(
|
||||
product,
|
||||
snapshotBinding.Rules,
|
||||
address.CountryCode,
|
||||
address.Province,
|
||||
address.City,
|
||||
address.ZipCode) ? (byte)1 : (byte)0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(
|
||||
order,
|
||||
ex is BusinessException be ? be.Message : $"预报快照解析失败:{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
||||
.OrderBy(x => x.Id)
|
||||
.ToList();
|
||||
var forecast = weights.FirstOrDefault(x =>
|
||||
string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase));
|
||||
if (forecast == null)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(order, "订单缺少预报件重尺");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||||
SyncWeightRows(weights, forecast);
|
||||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(
|
||||
order,
|
||||
ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
OrderBillingContext billing;
|
||||
try
|
||||
{
|
||||
billing = await ResolveOrderBillingContextAsync(
|
||||
order,
|
||||
product,
|
||||
forecast.ChargeableWeight ?? 0,
|
||||
snapshotBinding);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(
|
||||
order,
|
||||
ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await UpsertReceivableFeesAsync(
|
||||
order,
|
||||
billing,
|
||||
OrderFeeChargingStageCodes.Forecast,
|
||||
replaceExistingReceivable: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(
|
||||
order,
|
||||
ex is BusinessException be ? be.Message : $"预报应收计费失败:{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 排货失败不阻断预报成功(与原创单同步路径一致)
|
||||
await TryConfirmServiceChannelAndPayableAsync(
|
||||
order,
|
||||
product,
|
||||
billing,
|
||||
OrderFeeChargingStageCodes.Forecast);
|
||||
|
||||
try
|
||||
{
|
||||
await ApplyMarketingSnapshotOnCreateAsync(order, couponInstanceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("Oms.ForecastBilling");
|
||||
logger.LogWarning(ex, "预报营销快照失败 OrderId={OrderId}", order.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await CaptureForecastBillingSnapshotAsync(order);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
order.OrderStatus = OrderStatusCodes.Predeclared;
|
||||
order.PredeclareFailReason = string.Empty;
|
||||
order.UpdateTime = DateTime.Now;
|
||||
await OrderRepository.UpdateAsync(order);
|
||||
|
||||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
|
||||
await OperationLogWriter.TryWriteAsync(
|
||||
order,
|
||||
OrderOperationActionCodes.PreDeclare,
|
||||
beforeSnap,
|
||||
afterSnap,
|
||||
"预报计费/排货完成",
|
||||
operatorUserId > 0 ? operatorUserId : null,
|
||||
operatorName: null,
|
||||
operatorType: "staff",
|
||||
source: "job");
|
||||
|
||||
var customer = order.CustomerId.HasValue
|
||||
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
|
||||
: null;
|
||||
await PublishParcelCreateTrackAndLabelAsync(
|
||||
order,
|
||||
customer,
|
||||
receiver: null,
|
||||
appendInitialTrack: true,
|
||||
operatorUserId);
|
||||
|
||||
AutoTriggerRuntimeService.EnqueueFire(
|
||||
order.Id,
|
||||
AutoTriggerTiming.OrderCreate,
|
||||
operatorUserId);
|
||||
}
|
||||
|
||||
private sealed class ForecastBillingJobPayload
|
||||
{
|
||||
public int? CouponInstanceId { get; set; }
|
||||
}
|
||||
|
||||
private async Task EnqueueForecastBillingAsync(
|
||||
Order order,
|
||||
int? couponInstanceId,
|
||||
int operatorUserId)
|
||||
{
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("Oms.ForecastBilling");
|
||||
var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
|
||||
{
|
||||
CouponInstanceId = couponInstanceId
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
|
||||
if (!publisher.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorUserId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"预报计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}",
|
||||
order.Id,
|
||||
order.ServiceNo);
|
||||
await MarkParcelDraftFailedAsync(
|
||||
order,
|
||||
TrimPendingReason($"预报计费失败:{ex.Message}"));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}";
|
||||
await BatchJobCoordinator.CreateAndEnqueueAsync(
|
||||
new OrderBatchJobCreateInput
|
||||
{
|
||||
Action = OrderBatchActionCodes.ForecastBilling,
|
||||
OrderIds = [order.Id],
|
||||
RequestId = requestId,
|
||||
PayloadJson = payloadJson
|
||||
},
|
||||
operatorUserId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"预报计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}",
|
||||
order.Id,
|
||||
order.ServiceNo);
|
||||
try
|
||||
{
|
||||
await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnqueueInboundBillingAsync(Order order)
|
||||
{
|
||||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
@ -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<OrderParcelCreateOutput> 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<ISmallBagTrackOrderNotifier>();
|
||||
private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher =>
|
||||
LazyServiceProvider.GetRequiredService<IOrderParcelCreateEventPublisher>();
|
||||
|
||||
private async Task EnqueueGenerateLabelPdfAsync(Order order)
|
||||
private IOrderSideEffectEventPublisher SideEffectEventPublisher =>
|
||||
LazyServiceProvider.GetRequiredService<IOrderSideEffectEventPublisher>();
|
||||
|
||||
/// <summary>
|
||||
/// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。
|
||||
/// </summary>
|
||||
private async Task PublishParcelCreateTrackAndLabelAsync(
|
||||
Order order,
|
||||
Customer? customer,
|
||||
OrderParcelReceiverInput? receiver,
|
||||
bool appendInitialTrack,
|
||||
int? operatorUserId = null)
|
||||
{
|
||||
try
|
||||
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
|
||||
request.AppendInitialTrackOnSuccess = appendInitialTrack;
|
||||
var authorization = LazyServiceProvider
|
||||
.GetRequiredService<IHttpContextAccessor>()
|
||||
.HttpContext?
|
||||
.Request
|
||||
.Headers
|
||||
.Authorization
|
||||
.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(authorization))
|
||||
{
|
||||
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
|
||||
if (!publisher.IsEnabled)
|
||||
{
|
||||
// 本地未开队列:同步生成,避免 Pending 悬挂
|
||||
var mapped = (await MapOrderOutputsAsync([order])).First();
|
||||
await LabelPdfGenerationService.GenerateAndPersistAsync(
|
||||
order,
|
||||
mapped,
|
||||
operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 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<int>() : 0);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 投递失败不阻断创单;打印时会补生成
|
||||
}
|
||||
await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
|
||||
await ParcelCreateEventPublisher.PublishLabelGenerateAsync(
|
||||
order,
|
||||
operatorUserId
|
||||
?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
|
||||
/// 入库等非创单路径仍可调用;创单路径请走 <see cref="PublishParcelCreateTrackAndLabelAsync"/>。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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<IOrderParcelCreateEventPublisher>();
|
||||
try
|
||||
{
|
||||
var pickupService = LazyServiceProvider.GetRequiredService<PickupOrderService>();
|
||||
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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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";
|
||||
/// <summary>创单/草稿预报:预报应收计费 + 排货应付。</summary>
|
||||
public const string ForecastBilling = "forecast-billing";
|
||||
public const string Reallocate = "reallocate";
|
||||
public const string Export = "export";
|
||||
public const string ParcelCreate = "parcel-create";
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
@ -29,6 +29,12 @@ public class Order : OmsAuditedAggregateRoot
|
||||
/// </summary>
|
||||
public string? DownstreamSupplierCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下游 API 服务商名称(itfx service provider name)。
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 128, IsNullable = true)]
|
||||
public string? DownstreamSupplierName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下游 itfx 应用 AppKey。
|
||||
/// </summary>
|
||||
|
||||
36
src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs
Normal file
36
src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using Oms.Domain.Shared;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
[SugarTable("t_order_operation_log")]
|
||||
public class OrderOperationLog : OmsAuditedAggregateRoot
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
|
||||
[SugarColumn(Length = 64, IsNullable = true)]
|
||||
public string? ServiceNo { get; set; }
|
||||
|
||||
[SugarColumn(Length = 32)]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
[SugarColumn(Length = 64)]
|
||||
public string ActionName { get; set; } = string.Empty;
|
||||
|
||||
public int OperatorId { get; set; }
|
||||
|
||||
[SugarColumn(Length = 64, IsNullable = true)]
|
||||
public string? OperatorName { get; set; }
|
||||
|
||||
[SugarColumn(Length = 16)]
|
||||
public string OperatorType { get; set; } = "staff";
|
||||
|
||||
[SugarColumn(Length = 16)]
|
||||
public string Source { get; set; } = "pc";
|
||||
|
||||
[SugarColumn(ColumnDataType = "json", IsNullable = true)]
|
||||
public string? ChangesJson { get; set; }
|
||||
|
||||
[SugarColumn(Length = 512, IsNullable = true)]
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.OrdersAggregate.Repositories;
|
||||
|
||||
public interface IOrderOperationLogRepository : IRepository<OrderOperationLog>
|
||||
{
|
||||
}
|
||||
13
src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs
Normal file
13
src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using Oms.SqlSugarCore.Repositories;
|
||||
|
||||
namespace Oms.SqlSugarCore.Orders;
|
||||
|
||||
public class OrderOperationLogRepository
|
||||
: OmsSqlSugarRepository<OrderOperationLog>,
|
||||
IOrderOperationLogRepository,
|
||||
IScopedDependency
|
||||
{
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user