diff --git a/Directory.Build.props b/Directory.Build.props
index 7d6f04d..9c939ac 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 8.0.5.17
+ 8.0.5.18
diff --git a/db.sql b/db.sql
index b2c744a..3f1111e 100644
--- a/db.sql
+++ b/db.sql
@@ -104,6 +104,7 @@ DROP TABLE IF EXISTS `t_order_sender`;
DROP TABLE IF EXISTS `t_order_batch_job_item`;
DROP TABLE IF EXISTS `t_order_batch_job`;
DROP TABLE IF EXISTS `t_order_template`;
+DROP TABLE IF EXISTS `t_order_operation_log`;
DROP TABLE IF EXISTS `t_order_exception_event`;
DROP TABLE IF EXISTS `t_order`;
@@ -117,6 +118,7 @@ CREATE TABLE `t_order` (
`ProductCode` VARCHAR(128) NULL COMMENT '产品编码',
`ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码',
`DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode',
+ `DownstreamSupplierName` VARCHAR(128) NULL COMMENT '下游 API 服务商名称',
`DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey',
`DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id',
`ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode',
@@ -528,6 +530,29 @@ CREATE TABLE `t_order_exception_event` (
KEY `idx_oee_order_time` (`OrderId`, `CreateTime`)
) COMMENT='订单异常事件表';
+CREATE TABLE `t_order_operation_log` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `OrderId` INT NOT NULL COMMENT '运单Id',
+ `ServiceNo` VARCHAR(64) NULL COMMENT '运单号冗余',
+ `Action` VARCHAR(32) NOT NULL COMMENT '动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate',
+ `ActionName` VARCHAR(64) NOT NULL COMMENT '动作中文名',
+ `OperatorId` INT NOT NULL DEFAULT 0 COMMENT '操作人用户Id',
+ `OperatorName` VARCHAR(64) NULL COMMENT '操作人展示名',
+ `OperatorType` VARCHAR(16) NOT NULL DEFAULT 'staff' COMMENT 'staff/customer/system/openapi',
+ `Source` VARCHAR(16) NOT NULL DEFAULT 'pc' COMMENT 'pc/h5/openapi/job',
+ `ChangesJson` JSON NULL COMMENT '字段 diff 数组 [{path,label,before,after}]',
+ `Remark` VARCHAR(512) NULL COMMENT '补充说明',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `UpdateUserId` INT NULL COMMENT '更新人',
+ `UpdateTime` DATETIME NULL COMMENT '更新时间',
+ PRIMARY KEY (`Id`),
+ KEY `idx_ool_order_time` (`OrderId`, `CreateTime`),
+ KEY `idx_ool_service_no` (`ServiceNo`)
+) COMMENT='运单操作日志';
+
CREATE TABLE `t_sales_product` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码',
@@ -1329,6 +1354,8 @@ CREATE TABLE `t_warehouse_address` (
`Recipient` VARCHAR(64) NULL COMMENT '收件人',
`ContactPhone` VARCHAR(32) NULL COMMENT '联系电话',
`ShippingAddress` TEXT COMMENT '收货地址',
+ `Sort` INT NULL DEFAULT NULL COMMENT '展示排序,越小越靠前',
+ `Remark` VARCHAR(512) NULL DEFAULT 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 '创建人',
diff --git a/sql/admin1008.sql b/sql/admin1008.sql
index b516377..9c49440 100644
--- a/sql/admin1008.sql
+++ b/sql/admin1008.sql
@@ -624,3 +624,44 @@ ALTER TABLE `t_customer_export_qualification`
MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人',
MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间';
+
+-- =============================================================================
+-- migrate_warehouse_address_sort_remark.sql
+-- 仓库地址:展示排序(选填ï¼? 备注(选填ï¼?
+-- =============================================================================
+SET @db := DATABASE();
+
+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 ''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 ''Remark already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
diff --git a/sql/drop_all_unique_indexes.sql b/sql/drop_all_unique_indexes.sql
new file mode 100644
index 0000000..4f8d7cc
--- /dev/null
+++ b/sql/drop_all_unique_indexes.sql
@@ -0,0 +1,213 @@
+-- =============================================================================
+-- drop_all_unique_indexes.sql
+-- 去掉当前库所有非 PRIMARY 唯一索引的「唯一性」(改为同名普通 INDEX)
+-- 流程:备份并删除外键 → UNIQUE 改 KEY → 恢复外键
+-- 解决 Error 1553:Cannot drop index ... needed in a foreign key constraint
+-- 可重复执行;唯一性请在业务代码中校验。
+-- 请在 Navicat / DBeaver / MySQL Workbench 中整段执行(需要 DELIMITER)。
+-- =============================================================================
+
+SET @db := DATABASE();
+
+-- ---------- 预览:将处理的唯一索引 ----------
+SELECT
+ TABLE_NAME,
+ INDEX_NAME,
+ GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = @db
+ AND NON_UNIQUE = 0
+ AND INDEX_NAME <> 'PRIMARY'
+GROUP BY TABLE_NAME, INDEX_NAME
+ORDER BY TABLE_NAME, INDEX_NAME;
+
+-- ---------- 预览:库中物理外键(会临时删除再重建)----------
+SELECT
+ rc.CONSTRAINT_NAME,
+ rc.TABLE_NAME,
+ rc.REFERENCED_TABLE_NAME,
+ rc.UPDATE_RULE,
+ rc.DELETE_RULE
+FROM information_schema.REFERENTIAL_CONSTRAINTS rc
+WHERE rc.CONSTRAINT_SCHEMA = @db
+ORDER BY rc.TABLE_NAME, rc.CONSTRAINT_NAME;
+
+DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`;
+DELIMITER $$
+CREATE PROCEDURE `sp_drop_all_unique_indexes`()
+BEGIN
+ DECLARE done INT DEFAULT 0;
+ DECLARE v_fk_name VARCHAR(128);
+ DECLARE v_fk_table VARCHAR(128);
+ DECLARE v_ref_table VARCHAR(128);
+ DECLARE v_update VARCHAR(16);
+ DECLARE v_delete VARCHAR(16);
+ DECLARE v_cols TEXT;
+ DECLARE v_ref_cols TEXT;
+ DECLARE v_table VARCHAR(128);
+ DECLARE v_index VARCHAR(128);
+ DECLARE v_index_cols TEXT;
+ DECLARE v_sql TEXT;
+
+ -- 1) 备份当前库全部外键定义
+ DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`;
+ CREATE TEMPORARY TABLE `_tmp_fk_backup` (
+ `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ `constraint_name` VARCHAR(128) NOT NULL,
+ `table_name` VARCHAR(128) NOT NULL,
+ `column_names` TEXT NOT NULL,
+ `ref_table_name` VARCHAR(128) NOT NULL,
+ `ref_column_names` TEXT NOT NULL,
+ `update_rule` VARCHAR(16) NOT NULL,
+ `delete_rule` VARCHAR(16) NOT NULL
+ );
+
+ INSERT INTO `_tmp_fk_backup` (
+ `constraint_name`, `table_name`, `column_names`,
+ `ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule`
+ )
+ SELECT
+ kcu.CONSTRAINT_NAME,
+ kcu.TABLE_NAME,
+ GROUP_CONCAT(CONCAT('`', kcu.COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','),
+ kcu.REFERENCED_TABLE_NAME,
+ GROUP_CONCAT(CONCAT('`', kcu.REFERENCED_COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','),
+ rc.UPDATE_RULE,
+ rc.DELETE_RULE
+ FROM information_schema.KEY_COLUMN_USAGE kcu
+ INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
+ ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
+ AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
+ AND rc.TABLE_NAME = kcu.TABLE_NAME
+ WHERE kcu.TABLE_SCHEMA = DATABASE()
+ AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
+ GROUP BY
+ kcu.CONSTRAINT_NAME,
+ kcu.TABLE_NAME,
+ kcu.REFERENCED_TABLE_NAME,
+ rc.UPDATE_RULE,
+ rc.DELETE_RULE;
+
+ -- 2) 删除全部外键(解除对唯一索引的占用)
+ BEGIN
+ DECLARE cur_fk_drop CURSOR FOR
+ SELECT `constraint_name`, `table_name` FROM `_tmp_fk_backup` ORDER BY `id`;
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
+ SET done = 0;
+ OPEN cur_fk_drop;
+ fk_drop_loop: LOOP
+ FETCH cur_fk_drop INTO v_fk_name, v_fk_table;
+ IF done = 1 THEN
+ LEAVE fk_drop_loop;
+ END IF;
+ SET v_sql = CONCAT('ALTER TABLE `', v_fk_table, '` DROP FOREIGN KEY `', v_fk_name, '`');
+ SET @ddl := v_sql;
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END LOOP;
+ CLOSE cur_fk_drop;
+ END;
+
+ -- 3) 唯一索引 → 同名普通 INDEX(去掉唯一性,保留列与查询能力,便于外键重建)
+ BEGIN
+ DECLARE cur_uk CURSOR FOR
+ SELECT
+ s.TABLE_NAME,
+ s.INDEX_NAME,
+ GROUP_CONCAT(CONCAT('`', s.COLUMN_NAME, '`') ORDER BY s.SEQ_IN_INDEX SEPARATOR ',')
+ FROM information_schema.STATISTICS s
+ WHERE s.TABLE_SCHEMA = DATABASE()
+ AND s.NON_UNIQUE = 0
+ AND s.INDEX_NAME <> 'PRIMARY'
+ GROUP BY s.TABLE_NAME, s.INDEX_NAME
+ ORDER BY s.TABLE_NAME, s.INDEX_NAME;
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
+ SET done = 0;
+ OPEN cur_uk;
+ uk_loop: LOOP
+ FETCH cur_uk INTO v_table, v_index, v_index_cols;
+ IF done = 1 THEN
+ LEAVE uk_loop;
+ END IF;
+ SET v_sql = CONCAT(
+ 'ALTER TABLE `', v_table, '` DROP INDEX `', v_index,
+ '`, ADD INDEX `', v_index, '` (', v_index_cols, ')'
+ );
+ SET @ddl := v_sql;
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END LOOP;
+ CLOSE cur_uk;
+ END;
+
+ -- 4) 按备份重建外键
+ -- 若原外键引用的是「非主键唯一键」,去掉唯一性后 InnoDB 可能拒绝重建,将跳过并写入警告表
+ DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`;
+ CREATE TEMPORARY TABLE `_tmp_fk_restore_fail` (
+ `constraint_name` VARCHAR(128) NOT NULL,
+ `table_name` VARCHAR(128) NOT NULL,
+ `error_msg` TEXT
+ );
+
+ BEGIN
+ DECLARE cur_fk_add CURSOR FOR
+ SELECT
+ `constraint_name`, `table_name`, `column_names`,
+ `ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule`
+ FROM `_tmp_fk_backup`
+ ORDER BY `id`;
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
+ DECLARE CONTINUE HANDLER FOR SQLEXCEPTION
+ BEGIN
+ GET DIAGNOSTICS CONDITION 1 @fk_err = MESSAGE_TEXT;
+ INSERT INTO `_tmp_fk_restore_fail` (`constraint_name`, `table_name`, `error_msg`)
+ VALUES (v_fk_name, v_fk_table, @fk_err);
+ END;
+ SET done = 0;
+ OPEN cur_fk_add;
+ fk_add_loop: LOOP
+ FETCH cur_fk_add INTO
+ v_fk_name, v_fk_table, v_cols, v_ref_table, v_ref_cols, v_update, v_delete;
+ IF done = 1 THEN
+ LEAVE fk_add_loop;
+ END IF;
+ SET v_sql = CONCAT(
+ 'ALTER TABLE `', v_fk_table,
+ '` ADD CONSTRAINT `', v_fk_name,
+ '` FOREIGN KEY (', v_cols,
+ ') REFERENCES `', v_ref_table, '` (', v_ref_cols,
+ ') ON UPDATE ', v_update,
+ ' ON DELETE ', v_delete
+ );
+ SET @ddl := v_sql;
+ PREPARE stmt FROM @ddl;
+ EXECUTE stmt;
+ DEALLOCATE PREPARE stmt;
+ END LOOP;
+ CLOSE cur_fk_add;
+ END;
+
+ -- 输出重建失败的外键(通常因引用侧不再是 UNIQUE/PRIMARY)
+ SELECT * FROM `_tmp_fk_restore_fail`;
+
+ DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`;
+ DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`;
+END$$
+DELIMITER ;
+
+CALL `sp_drop_all_unique_indexes`();
+DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`;
+
+-- ---------- 校验:唯一索引应无结果(PRIMARY 除外)----------
+SELECT
+ TABLE_NAME,
+ INDEX_NAME,
+ GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = @db
+ AND NON_UNIQUE = 0
+ AND INDEX_NAME <> 'PRIMARY'
+GROUP BY TABLE_NAME, INDEX_NAME
+ORDER BY TABLE_NAME, INDEX_NAME;
diff --git a/sql/migrate_20261009_order_warehouse.sql b/sql/migrate_20261009_order_warehouse.sql
new file mode 100644
index 0000000..c8c2b84
--- /dev/null
+++ b/sql/migrate_20261009_order_warehouse.sql
@@ -0,0 +1,105 @@
+-- =============================================================================
+-- migrate_20261009_order_warehouse.sql
+-- 汇总:
+-- 1) t_order.DownstreamSupplierName(下游服务商名称)
+-- 2) t_order_operation_log(运单操作日志)
+-- 3) t_warehouse_address.Sort / Remark(仓库地址排序与备注)
+-- 可重复执行。
+-- =============================================================================
+
+SET @db := DATABASE();
+
+-- -----------------------------------------------------------------------------
+-- 1) 订单:下游服务商名称
+-- -----------------------------------------------------------------------------
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'DownstreamSupplierName'
+);
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`',
+ 'SELECT ''t_order.DownstreamSupplierName already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 2) 运单操作日志表
+-- -----------------------------------------------------------------------------
+SET @tbl := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_operation_log'
+);
+SET @sql := IF(
+ @tbl = 0,
+ 'CREATE TABLE `t_order_operation_log` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `OrderId` INT NOT NULL COMMENT ''运单Id'',
+ `ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'',
+ `Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'',
+ `ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'',
+ `OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'',
+ `OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'',
+ `OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'',
+ `Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'',
+ `ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'',
+ `Remark` VARCHAR(512) NULL COMMENT ''补充说明'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ KEY `idx_ool_order_time` (`OrderId`, `CreateTime`),
+ KEY `idx_ool_service_no` (`ServiceNo`)
+ ) COMMENT=''运单操作日志''',
+ 'SELECT ''t_order_operation_log already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- -----------------------------------------------------------------------------
+-- 3) 仓库地址:展示排序 + 备注
+-- -----------------------------------------------------------------------------
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_warehouse_address'
+ AND COLUMN_NAME = 'Sort'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_warehouse_address`
+ ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`',
+ 'SELECT ''t_warehouse_address.Sort already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @col := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_warehouse_address'
+ AND COLUMN_NAME = 'Remark'
+);
+SET @sql := IF(
+ @col = 0,
+ 'ALTER TABLE `t_warehouse_address`
+ ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`',
+ 'SELECT ''t_warehouse_address.Remark already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_order_downstream_supplier_name.sql b/sql/migrate_order_downstream_supplier_name.sql
new file mode 100644
index 0000000..f3ba5ad
--- /dev/null
+++ b/sql/migrate_order_downstream_supplier_name.sql
@@ -0,0 +1,22 @@
+-- 订单增加下游服务商名称(排货确认时写入 itfx 服务商名 / 分组名)。
+-- 可重复执行。
+
+SET @db := DATABASE();
+
+SET @exists := (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order'
+ AND COLUMN_NAME = 'DownstreamSupplierName'
+);
+
+SET @sql := IF(
+ @exists = 0,
+ 'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`',
+ 'SELECT ''t_order.DownstreamSupplierName already exists'' AS info'
+);
+
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_order_operation_log.sql b/sql/migrate_order_operation_log.sql
new file mode 100644
index 0000000..cd0aba0
--- /dev/null
+++ b/sql/migrate_order_operation_log.sql
@@ -0,0 +1,42 @@
+-- =============================================================================
+-- migrate_order_operation_log.sql
+-- 运单操作日志表(业务动作 + 字段级 before/after diff)
+-- 可重复执行
+-- =============================================================================
+SET @db := DATABASE();
+
+SET @tbl := (
+ SELECT COUNT(1)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_order_operation_log'
+);
+SET @sql := IF(
+ @tbl = 0,
+ 'CREATE TABLE `t_order_operation_log` (
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'',
+ `OrderId` INT NOT NULL COMMENT ''运单Id'',
+ `ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'',
+ `Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'',
+ `ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'',
+ `OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'',
+ `OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'',
+ `OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'',
+ `Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'',
+ `ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'',
+ `Remark` VARCHAR(512) NULL COMMENT ''补充说明'',
+ `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'',
+ `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'',
+ `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'',
+ `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',
+ `UpdateUserId` INT NULL COMMENT ''更新人'',
+ `UpdateTime` DATETIME NULL COMMENT ''更新时间'',
+ PRIMARY KEY (`Id`),
+ KEY `idx_ool_order_time` (`OrderId`, `CreateTime`),
+ KEY `idx_ool_service_no` (`ServiceNo`)
+ ) COMMENT=''运单操作日志''',
+ 'SELECT ''t_order_operation_log already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/sql/migrate_warehouse_address_sort_remark.sql b/sql/migrate_warehouse_address_sort_remark.sql
new file mode 100644
index 0000000..e4c047b
--- /dev/null
+++ b/sql/migrate_warehouse_address_sort_remark.sql
@@ -0,0 +1,39 @@
+-- =============================================================================
+-- migrate_warehouse_address_sort_remark.sql
+-- 仓库地址:展示排序(选填)+ 备注(选填)
+-- =============================================================================
+SET @db := DATABASE();
+
+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 ''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 ''Remark already exists'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs
new file mode 100644
index 0000000..2abfce4
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs
@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 切换订单绑定的销售产品规则/价格发布版本。
+///
+public class OrderBindSnapshotVersionsInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ ///
+ /// 目标规则版本;空表示不改。特殊值 CURRENT 表示绑定当前有效规则发布。
+ ///
+ [StringLength(64)]
+ public string? RulesVersion { get; set; }
+
+ ///
+ /// 目标价格版本;空表示不改。特殊值 CURRENT 表示绑定当前有效价格发布。
+ ///
+ [StringLength(64)]
+ public string? PriceVersion { get; set; }
+
+ ///
+ /// 绑定后是否立即重算应收(已支付会拒绝)。
+ ///
+ public bool RecalculateReceivable { get; set; }
+
+ ///
+ /// 绑定后是否立即重算应付(锁定当前渠道)。
+ ///
+ public bool RecalculatePayable { get; set; }
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs
new file mode 100644
index 0000000..3b787e0
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs
@@ -0,0 +1,44 @@
+namespace Oms.Application.Dtos.Orders;
+
+public class OrderOperationLogChangeItem
+{
+ public string Path { get; set; } = string.Empty;
+
+ public string Label { get; set; } = string.Empty;
+
+ public string? Before { get; set; }
+
+ public string? After { get; set; }
+}
+
+public class OrderOperationLogOutput
+{
+ public int Id { get; set; }
+
+ public int OrderId { get; set; }
+
+ public string? ServiceNo { get; set; }
+
+ public string Action { get; set; } = string.Empty;
+
+ public string ActionName { get; set; } = string.Empty;
+
+ public int OperatorId { get; set; }
+
+ public string? OperatorName { get; set; }
+
+ public string OperatorType { get; set; } = string.Empty;
+
+ public string Source { get; set; } = string.Empty;
+
+ public List Changes { get; set; } = [];
+
+ public string? Remark { get; set; }
+
+ public DateTime CreateTime { get; set; }
+}
+
+public class OrderOperationLogListOutput
+{
+ public List Items { get; set; } = [];
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index a82aea9..4eaed1f 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -55,6 +55,11 @@ public class OrderOutput
///
public string? DownstreamSupplierCode { get; set; }
+ ///
+ /// 下游 API 服务商名称。
+ ///
+ public string? DownstreamSupplierName { get; set; }
+
///
/// 下游 itfx 应用 AppKey。
///
@@ -363,6 +368,16 @@ public class OrderOutput
///
public decimal? ForecastPayableAmount { get; set; }
+ ///
+ /// 订单绑定的销售产品规则发布版本。
+ ///
+ public string? SalesProductRulesVersion { get; set; }
+
+ ///
+ /// 订单绑定的销售产品价格发布版本。
+ ///
+ public string? SalesProductPriceVersion { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
index d9fd2fd..3633ccd 100644
--- a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
+++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
@@ -176,6 +176,44 @@ public class PickupOrderRescheduleInput
public DateTime ExpectPickupTime { get; set; }
}
+///
+/// 修改揽收信息(未支付且 draft/scheduled)。
+///
+public class PickupOrderUpdateInfoInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ public int? WarehouseId { get; set; }
+
+ public int? SenderAddressId { get; set; }
+
+ public DateTime? ExpectPickupTime { get; set; }
+
+ public string? CarrierService { get; set; }
+
+ public string? CarrierWaybillNo { get; set; }
+
+ [StringLength(512)]
+ public string? Remark { get; set; }
+}
+
+///
+/// 修改件重尺(未支付;不改费用,需另行重算)。
+///
+public class PickupOrderUpdateMeasureInput
+{
+ [Required]
+ public int Id { get; set; }
+
+ [Required]
+ public decimal TotalActualWeight { get; set; }
+
+ [Required]
+ [MinLength(1)]
+ public List PackageSizes { get; set; } = [];
+}
+
public class PickupOrderRejectItem
{
public int OrderId { get; set; }
diff --git a/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs b/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs
index 50c4a90..6e2c66b 100644
--- a/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs
+++ b/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs
@@ -37,4 +37,15 @@ public class CreateWarehouseInput
/// 发货地址。
///
public string? ShippingAddress { get; set; }
+
+ ///
+ /// 展示排序(越小越靠前;可不填)。
+ ///
+ public int? Sort { get; set; }
+
+ ///
+ /// 备注(可不填)。
+ ///
+ [StringLength(512)]
+ public string? Remark { get; set; }
}
diff --git a/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs b/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs
index ea8b6af..77b89c5 100644
--- a/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs
+++ b/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs
@@ -43,4 +43,15 @@ public class UpdateWarehouseInput
/// 发货地址。
///
public string? ShippingAddress { get; set; }
+
+ ///
+ /// 展示排序(越小越靠前;可不填)。
+ ///
+ public int? Sort { get; set; }
+
+ ///
+ /// 备注(可不填)。
+ ///
+ [StringLength(512)]
+ public string? Remark { get; set; }
}
diff --git a/src/Oms.Application/Dtos/System/WarehouseOutput.cs b/src/Oms.Application/Dtos/System/WarehouseOutput.cs
index 95bf877..26314b6 100644
--- a/src/Oms.Application/Dtos/System/WarehouseOutput.cs
+++ b/src/Oms.Application/Dtos/System/WarehouseOutput.cs
@@ -35,6 +35,16 @@ public class WarehouseOutput
///
public string? ShippingAddress { get; set; }
+ ///
+ /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。
+ ///
+ public int? Sort { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Remark { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs b/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs
index a6b792e..1067733 100644
--- a/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs
+++ b/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs
@@ -40,6 +40,16 @@ public class WarehousePaginatedOutput
///
public string? ShippingAddress { get; set; }
+ ///
+ /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。
+ ///
+ public int? Sort { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Remark { get; set; }
+
///
/// 创建时间。
///
diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs
new file mode 100644
index 0000000..527471a
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs
@@ -0,0 +1,251 @@
+using System.Globalization;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Application.Services.Orders;
+
+///
+/// 运单操作日志白名单快照与字段 label。
+///
+public static class OrderOperationLogSnapshot
+{
+ private static readonly Dictionary Labels = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["orderStatus"] = "订单状态",
+ ["clientNo"] = "客户单号",
+ ["trackNo"] = "跟踪号",
+ ["channelCode"] = "服务渠道",
+ ["productCode"] = "销售产品",
+ ["warehouseId"] = "仓库",
+ ["cargoAttribute"] = "货物属性",
+ ["isCod"] = "是否COD",
+ ["codCurrency"] = "COD币种",
+ ["codSum"] = "COD金额",
+ ["declaredCurrency"] = "申报币种",
+ ["declaredSum"] = "申报金额",
+ ["quantity"] = "包裹数量",
+ ["receivableSubtotal"] = "应收小计",
+ ["payableAmount"] = "应付金额",
+ ["downstreamServiceNo"] = "服务商单号",
+ ["allocationScheme"] = "排货方案",
+ ["salesProductRulesVersion"] = "规则版本",
+ ["salesProductPriceVersion"] = "价格版本",
+ ["isPaid"] = "是否已支付",
+ ["sender.name"] = "发件人姓名",
+ ["sender.phone"] = "发件人电话",
+ ["sender.country"] = "发件国家",
+ ["sender.province"] = "发件省/州",
+ ["sender.city"] = "发件城市",
+ ["sender.zipCode"] = "发件邮编",
+ ["sender.address1"] = "发件地址1",
+ ["sender.address2"] = "发件地址2",
+ ["receiver.name"] = "收件人姓名",
+ ["receiver.phone"] = "收件人电话",
+ ["receiver.country"] = "收件国家",
+ ["receiver.province"] = "收件省/州",
+ ["receiver.city"] = "收件城市",
+ ["receiver.zipCode"] = "收件邮编",
+ ["receiver.address1"] = "收件地址1",
+ ["receiver.address2"] = "收件地址2",
+ ["fee.receivableTotal"] = "应收合计",
+ ["fee.payableTotal"] = "应付合计",
+ };
+
+ public static Dictionary CaptureOrder(Order order)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["orderStatus"] = Norm(order.OrderStatus),
+ ["clientNo"] = Norm(order.ClientNo),
+ ["trackNo"] = Norm(order.TrackNo),
+ ["channelCode"] = Norm(order.ChannelCode),
+ ["productCode"] = Norm(order.ProductCode),
+ ["warehouseId"] = FormatInt(order.WarehouseId),
+ ["cargoAttribute"] = Norm(order.CargoAttribute),
+ ["isCod"] = FormatByte(order.IsCod),
+ ["codCurrency"] = Norm(order.CodCurrency),
+ ["codSum"] = FormatDecimal(order.CodSum),
+ ["declaredCurrency"] = Norm(order.DeclaredCurrency),
+ ["declaredSum"] = FormatDecimal(order.DeclaredSum),
+ ["quantity"] = FormatInt(order.Quantity),
+ ["receivableSubtotal"] = FormatDecimal(order.ReceivableSubtotal),
+ ["payableAmount"] = FormatDecimal(order.PayableAmount),
+ ["downstreamServiceNo"] = Norm(order.DownstreamServiceNo),
+ ["allocationScheme"] = Norm(order.AllocationScheme),
+ ["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion),
+ ["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion),
+ ["isPaid"] = FormatByte(order.IsPaid),
+ };
+ }
+
+ public static Dictionary CaptureSender(OrderSender? sender)
+ {
+ if (sender == null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["sender.name"] = Norm(sender.SenderName),
+ ["sender.phone"] = Norm(sender.Phone),
+ ["sender.country"] = FormatInt(sender.Country),
+ ["sender.province"] = Norm(sender.Province),
+ ["sender.city"] = Norm(sender.City),
+ ["sender.zipCode"] = Norm(sender.ZipCode),
+ ["sender.address1"] = Norm(sender.StreetAddress1),
+ ["sender.address2"] = Norm(sender.StreetAddress2),
+ };
+ }
+
+ public static Dictionary CaptureReceiver(OrderRecipient? recipient)
+ {
+ if (recipient == null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["receiver.name"] = Norm(recipient.RecipientsName),
+ ["receiver.phone"] = Norm(recipient.Phone),
+ ["receiver.country"] = FormatInt(recipient.Country),
+ ["receiver.province"] = Norm(recipient.Province),
+ ["receiver.city"] = Norm(recipient.City),
+ ["receiver.zipCode"] = Norm(recipient.ZipCode),
+ ["receiver.address1"] = Norm(recipient.StreetAddress1),
+ ["receiver.address2"] = Norm(recipient.StreetAddress2),
+ };
+ }
+
+ public static Dictionary CaptureWeights(IEnumerable weights)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var row in weights)
+ {
+ var type = string.IsNullOrWhiteSpace(row.Type) ? "unknown" : row.Type.Trim().ToLowerInvariant();
+ var prefix = $"weight.{type}";
+ map[$"{prefix}.actualWeight"] = FormatDecimal(row.ActualWeight);
+ map[$"{prefix}.length"] = FormatDecimal(row.Length);
+ map[$"{prefix}.width"] = FormatDecimal(row.Width);
+ map[$"{prefix}.height"] = FormatDecimal(row.Height);
+ map[$"{prefix}.chargeableWeight"] = FormatDecimal(row.ChargeableWeight);
+ }
+
+ return map;
+ }
+
+ public static Dictionary CaptureFeeTotals(IEnumerable fees)
+ {
+ var list = fees.ToList();
+ var receivable = list
+ .Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Receivable, StringComparison.OrdinalIgnoreCase))
+ .Sum(x => x.Amount);
+ var payable = list
+ .Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Payable, StringComparison.OrdinalIgnoreCase))
+ .Sum(x => x.Amount);
+
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["fee.receivableTotal"] = FormatDecimal(receivable),
+ ["fee.payableTotal"] = FormatDecimal(payable),
+ };
+ }
+
+ public static Dictionary Merge(params IReadOnlyDictionary[] parts)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var part in parts)
+ {
+ foreach (var (key, value) in part)
+ {
+ map[key] = value;
+ }
+ }
+
+ return map;
+ }
+
+ public static List<(string Path, string Label, string? Before, string? After)> Diff(
+ IReadOnlyDictionary before,
+ IReadOnlyDictionary after)
+ {
+ var keys = before.Keys.Union(after.Keys, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
+ var changes = new List<(string, string, string?, string?)>();
+ foreach (var key in keys)
+ {
+ before.TryGetValue(key, out var b);
+ after.TryGetValue(key, out var a);
+ b = Norm(b);
+ a = Norm(a);
+ if (string.Equals(b, a, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ changes.Add((key, ResolveLabel(key), b, a));
+ }
+
+ return changes;
+ }
+
+ public static string ResolveLabel(string path)
+ {
+ if (Labels.TryGetValue(path, out var label))
+ {
+ return label;
+ }
+
+ if (path.StartsWith("weight.", StringComparison.OrdinalIgnoreCase))
+ {
+ var parts = path.Split('.');
+ if (parts.Length >= 3)
+ {
+ var typeName = parts[1] switch
+ {
+ "forecast" => "预报",
+ "receive" => "收货",
+ "ship" => "发货",
+ "downstream" => "对账",
+ _ => parts[1]
+ };
+ var fieldName = parts[2] switch
+ {
+ "actualWeight" => "实重",
+ "length" => "长",
+ "width" => "宽",
+ "height" => "高",
+ "chargeableWeight" => "计费重",
+ _ => parts[2]
+ };
+ return $"{typeName}{fieldName}";
+ }
+ }
+
+ return path;
+ }
+
+ private static string? Norm(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ return value.Trim();
+ }
+
+ private static string? FormatDecimal(decimal? value)
+ {
+ if (!value.HasValue)
+ {
+ return null;
+ }
+
+ return value.Value.ToString("0.####", CultureInfo.InvariantCulture);
+ }
+
+ private static string? FormatInt(int? value) => value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : null;
+
+ private static string FormatByte(byte value) => value == 0 ? "0" : "1";
+}
diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs
new file mode 100644
index 0000000..d5b3aea
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs
@@ -0,0 +1,132 @@
+using Fake.DependencyInjection;
+using Fake.Security.Claims;
+using Fake.Users;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+
+namespace Oms.Application.Services.Orders;
+
+///
+/// 运单操作日志写入器:字段 diff 落库;失败不影响主业务。
+///
+public class OrderOperationLogWriter : IScopedDependency
+{
+ private readonly IOrderOperationLogRepository _repository;
+ private readonly ICurrentUser _currentUser;
+ private readonly OmsRbacUserDisplayNameResolver _userDisplayNameResolver;
+ private readonly ILogger _logger;
+
+ public OrderOperationLogWriter(
+ IOrderOperationLogRepository repository,
+ ICurrentUser currentUser,
+ OmsRbacUserDisplayNameResolver userDisplayNameResolver,
+ ILogger logger)
+ {
+ _repository = repository;
+ _currentUser = currentUser;
+ _userDisplayNameResolver = userDisplayNameResolver;
+ _logger = logger;
+ }
+
+ public async Task TryWriteAsync(
+ Order order,
+ string action,
+ IReadOnlyDictionary before,
+ IReadOnlyDictionary after,
+ string? remark = null)
+ {
+ try
+ {
+ var changes = OrderOperationLogSnapshot.Diff(before, after);
+ var operatorId = 0;
+ string? operatorName = null;
+ if (_currentUser.IsAuthenticated)
+ {
+ operatorId = _currentUser.GetId();
+ operatorName = await _userDisplayNameResolver.ResolveAsync(operatorId);
+ }
+
+ var entity = new OrderOperationLog
+ {
+ OrderId = order.Id,
+ ServiceNo = order.ServiceNo,
+ Action = action,
+ ActionName = OrderOperationActionCodes.GetActionName(action),
+ OperatorId = operatorId,
+ OperatorName = operatorName,
+ OperatorType = "staff",
+ Source = "pc",
+ 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 = operatorId,
+ CreateTime = DateTime.Now
+ };
+
+ await _repository.InsertAsync(entity);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "写入运单操作日志失败 OrderId={OrderId} Action={Action}",
+ order.Id,
+ action);
+ }
+ }
+
+ public async Task ListByOrderIdAsync(int orderId)
+ {
+ var rows = await _repository.GetListAsync(x => x.OrderId == orderId);
+ var items = rows
+ .OrderByDescending(x => x.CreateTime)
+ .ThenByDescending(x => x.Id)
+ .Select(MapOutput)
+ .ToList();
+ return new OrderOperationLogListOutput { Items = items };
+ }
+
+ private static OrderOperationLogOutput MapOutput(OrderOperationLog row)
+ {
+ var changes = new List();
+ if (!string.IsNullOrWhiteSpace(row.ChangesJson))
+ {
+ try
+ {
+ changes = JsonConvert.DeserializeObject>(row.ChangesJson)
+ ?? [];
+ }
+ catch
+ {
+ changes = [];
+ }
+ }
+
+ 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,
+ CreateTime = row.CreateTime
+ };
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index c5d6e34..40f81fa 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -165,6 +165,7 @@ public partial class OrderService
MaxTimeliness = product?.MaxTimeliness,
ChannelCode = order.ChannelCode,
DownstreamSupplierCode = order.DownstreamSupplierCode,
+ DownstreamSupplierName = order.DownstreamSupplierName,
DownstreamAppKey = order.DownstreamAppKey,
DownstreamAppId = order.DownstreamAppId,
ChannelForecastCode = order.ChannelForecastCode,
@@ -262,6 +263,12 @@ public partial class OrderService
HasInsurance = hasInsurance,
ForecastReceivableAmount = order.ForecastReceivableAmount,
ForecastPayableAmount = order.ForecastPayableAmount,
+ SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
+ ? null
+ : order.SalesProductRulesVersion.Trim(),
+ SalesProductPriceVersion = string.IsNullOrWhiteSpace(order.SalesProductPriceVersion)
+ ? null
+ : order.SalesProductPriceVersion.Trim(),
CreateTime = order.CreateTime,
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap),
UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime),
@@ -387,6 +394,7 @@ public partial class OrderService
output.ChannelCode = null;
output.ChannelName = null;
output.DownstreamSupplierCode = null;
+ output.DownstreamSupplierName = null;
output.DownstreamAppKey = null;
output.DownstreamAppId = null;
output.ChannelForecastCode = null;
diff --git a/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs
new file mode 100644
index 0000000..eefa146
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Orders;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Application.Services.Orders;
+
+public partial class OrderService
+{
+ private OrderOperationLogWriter OperationLogWriter =>
+ LazyServiceProvider.GetRequiredService();
+
+ ///
+ /// 运单操作日志列表(按时间倒序)。
+ ///
+ [HttpGet("/order/operation-log/list")]
+ public async Task GetOperationLogListAsync([FromQuery] int orderId)
+ {
+ await GetOrderOrThrowAsync(orderId);
+ return await OperationLogWriter.ListByOrderIdAsync(orderId);
+ }
+
+ private async Task> CaptureOrderAuditSnapshotAsync(
+ Order order,
+ bool includeParties = false,
+ bool includeWeights = false,
+ bool includeFees = false)
+ {
+ var parts = new List>
+ {
+ OrderOperationLogSnapshot.CaptureOrder(order)
+ };
+
+ if (includeParties)
+ {
+ var senders = await OrderRepository.GetSendersByOrderIdsAsync([order.Id]);
+ var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
+ parts.Add(OrderOperationLogSnapshot.CaptureSender(senders.FirstOrDefault()));
+ parts.Add(OrderOperationLogSnapshot.CaptureReceiver(recipients.FirstOrDefault()));
+ }
+
+ if (includeWeights)
+ {
+ var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
+ parts.Add(OrderOperationLogSnapshot.CaptureWeights(weights));
+ }
+
+ if (includeFees)
+ {
+ var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
+ parts.Add(OrderOperationLogSnapshot.CaptureFeeTotals(fees));
+ }
+
+ return OrderOperationLogSnapshot.Merge(parts.ToArray());
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index 616c9f0..9049e59 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -28,6 +28,7 @@ public partial class OrderService
public async Task UpdateOrderDetailAsync(OrderUpdateInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true);
if (input.ClientNo != null)
{
@@ -134,6 +135,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 +160,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 +236,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 +254,16 @@ public partial class OrderService
public async Task RecalculateReceivableFeesAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
EnsureReceivableMutable(order);
await RecalculateReceivableCoreAsync(order);
+ order = await GetOrderOrThrowAsync(order.Id);
+ var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
+ await OperationLogWriter.TryWriteAsync(
+ order,
+ OrderOperationActionCodes.RecalcReceivable,
+ beforeSnap,
+ afterSnap);
return (await MapOrderOutputsAsync([order])).First();
}
@@ -246,10 +274,139 @@ public partial class OrderService
public async Task RecalculatePayableFeesAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await RecalculatePayableCoreAsync(order);
+ order = await GetOrderOrThrowAsync(order.Id);
+ var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
+ await OperationLogWriter.TryWriteAsync(
+ order,
+ OrderOperationActionCodes.RecalcPayable,
+ beforeSnap,
+ afterSnap);
return (await MapOrderOutputsAsync([order])).First();
}
+ ///
+ /// 切换订单绑定的销售产品规则/价格发布版本(显式改契约后再重算)。
+ ///
+ [HttpPost("/order/fee/bind-snapshot-versions")]
+ public async Task BindSnapshotVersionsAsync([FromBody] OrderBindSnapshotVersionsInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
+
+ if (string.IsNullOrWhiteSpace(order.ProductCode))
+ {
+ throw new BusinessException("订单未绑定销售产品,无法切换版本");
+ }
+
+ var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
+ if (product == null)
+ {
+ throw new BusinessException("销售产品不存在");
+ }
+
+ var wantRules = !string.IsNullOrWhiteSpace(input.RulesVersion);
+ var wantPrice = !string.IsNullOrWhiteSpace(input.PriceVersion);
+ if (!wantRules && !wantPrice)
+ {
+ throw new BusinessException("请至少指定规则版本或价格版本");
+ }
+
+ var remarkParts = new List();
+ if (wantRules)
+ {
+ var resolved = await ResolveBindTargetVersionAsync(
+ product,
+ RulesOperatorType,
+ input.RulesVersion!.Trim(),
+ "规则");
+ if (!string.Equals(order.SalesProductRulesVersion, resolved, StringComparison.Ordinal))
+ {
+ remarkParts.Add($"rules:{order.SalesProductRulesVersion ?? "-"}→{resolved}");
+ order.SalesProductRulesVersion = resolved;
+ }
+ }
+
+ if (wantPrice)
+ {
+ var resolved = await ResolveBindTargetVersionAsync(
+ product,
+ PriceOperatorType,
+ input.PriceVersion!.Trim(),
+ "价格");
+ if (!string.Equals(order.SalesProductPriceVersion, resolved, StringComparison.Ordinal))
+ {
+ remarkParts.Add($"price:{order.SalesProductPriceVersion ?? "-"}→{resolved}");
+ order.SalesProductPriceVersion = resolved;
+ }
+ }
+
+ if (remarkParts.Count == 0 && !input.RecalculateReceivable && !input.RecalculatePayable)
+ {
+ throw new BusinessException("目标版本与当前绑定相同,且未勾选重算");
+ }
+
+ order.UpdateTime = DateTime.Now;
+ await OrderRepository.UpdateAsync(order);
+
+ if (input.RecalculateReceivable)
+ {
+ EnsureReceivableMutable(order);
+ await RecalculateReceivableCoreAsync(order);
+ }
+
+ if (input.RecalculatePayable)
+ {
+ await RecalculatePayableCoreAsync(order);
+ }
+
+ order = await GetOrderOrThrowAsync(order.Id);
+ var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
+ await OperationLogWriter.TryWriteAsync(
+ order,
+ OrderOperationActionCodes.BindSnapshotVersions,
+ beforeSnap,
+ afterSnap,
+ remarkParts.Count == 0 ? null : string.Join(';', remarkParts));
+ return (await MapOrderOutputsAsync([order])).First();
+ }
+
+ ///
+ /// 解析绑定目标版本:CURRENT 取当前有效发布,否则按版本号校验存在。
+ ///
+ private async Task ResolveBindTargetVersionAsync(
+ SalesProduct product,
+ string operatorType,
+ string versionOrCurrent,
+ string label)
+ {
+ if (string.Equals(versionOrCurrent, "CURRENT", StringComparison.OrdinalIgnoreCase))
+ {
+ var effective = await SalesProductRepository.GetEffectivePublishRecordAsync(
+ product.Id,
+ operatorType,
+ DateTime.Now);
+ if (effective == null || string.IsNullOrWhiteSpace(effective.Version))
+ {
+ throw new BusinessException($"未命中当前有效{label}发布版本");
+ }
+
+ return effective.Version.Trim();
+ }
+
+ var record = await SalesProductRepository.GetPublishRecordByVersionAsync(
+ product.Id,
+ operatorType,
+ versionOrCurrent);
+ if (record == null || string.IsNullOrWhiteSpace(record.Version))
+ {
+ throw new BusinessException($"未找到{label}发布版本:{versionOrCurrent}");
+ }
+
+ return record.Version.Trim();
+ }
+
///
/// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。
///
@@ -566,8 +723,17 @@ public partial class OrderService
public async Task ReallocateAsync([FromBody] OrderReallocateInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
+ var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId);
order = await GetOrderOrThrowAsync(input.Id);
+ var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
+ var remark = string.IsNullOrWhiteSpace(input.Scheme) ? null : $"scheme={input.Scheme.Trim()}";
+ await OperationLogWriter.TryWriteAsync(
+ order,
+ OrderOperationActionCodes.Reallocate,
+ beforeSnap,
+ afterSnap,
+ remark);
return (await MapOrderOutputsAsync([order])).First();
}
@@ -580,12 +746,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)
@@ -955,6 +1131,7 @@ public partial class OrderService
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
// 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。
+ // 锁渠道重算应付:允许渠道不在旧规则排货池内(渠道已确认,只重算买价)。
var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order);
@@ -977,7 +1154,14 @@ public partial class OrderService
billing,
chargingStage,
schemeOverride,
- forceChannelId);
+ forceChannelId,
+ allowDesignatedOutsidePool: lockCurrentChannel);
+
+ // 重新排货成功后写回规则版本,使后续重算应付与当前排货池一致;卖价版本保持契约,不随排货改写。
+ if (enforceReallocateGate)
+ {
+ order.SalesProductRulesVersion = snapshotBinding.RulesVersion;
+ }
if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
{
@@ -990,15 +1174,15 @@ public partial class OrderService
private static void EnsureReallocateAllowed(Order order)
{
- if (order.IsPaid != 0)
- {
- throw new BusinessException("订单已付款,不允许重新排货");
- }
-
- if (order.OrderStatus is not (OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment))
+ // 已付款也可排货:只重算应付与服务渠道,不改应收。
+ if (order.OrderStatus is not (
+ OrderStatusCodes.Predeclared
+ or OrderStatusCodes.PendingAllocation
+ or OrderStatusCodes.PendingPayment
+ or OrderStatusCodes.PendingShip))
{
throw new BusinessException(
- $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅待排货/待支付可重新排货");
+ $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅已预报/待排货/待支付/待发货可重新排货");
}
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 2fd317d..8f7bc82 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -848,7 +848,8 @@ public partial class OrderService
string chargingStage,
string? _,
string? schemeOverride = null,
- int? forceServiceChannelId = null)
+ int? forceServiceChannelId = null,
+ bool allowDesignatedOutsidePool = false)
{
var evaluation = await EvaluateAllocationAsync(
order,
@@ -858,7 +859,8 @@ public partial class OrderService
chargeableWeight,
chargingStage,
schemeOverride,
- forceServiceChannelId);
+ forceServiceChannelId,
+ allowDesignatedOutsidePool);
ApplyAllocationTraceToOrder(order, evaluation);
@@ -928,7 +930,8 @@ public partial class OrderService
decimal chargeableWeight,
string chargingStage,
string? schemeOverride,
- int? forceServiceChannelId)
+ int? forceServiceChannelId,
+ bool allowDesignatedOutsidePool = false)
{
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
@@ -985,27 +988,61 @@ public partial class OrderService
if (designated.Count == 0)
{
var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value);
- var failReason = poolRow == null
- ? "指定渠道不在该销售产品排货池"
- : "指定渠道不满足判断条件";
- if (poolRow != null
- && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
+ // 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。
+ if (allowDesignatedOutsidePool)
{
- skipped.Add(new OrderAllocationSkippedOutput
+ var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
+ x.Id == forceServiceChannelId.Value);
+ if (lockedChannel == null)
{
- ServiceChannelId = forceServiceChannelId.Value,
- ChannelCode = poolRow.ChannelCode,
- ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
- Stage = AllocationResolver.SkipStageCondition,
- MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
- Reason = failReason
- });
+ return BaseEval("订单服务渠道不存在,无法重算应付");
+ }
+
+ var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto
+ {
+ SalesProductId = product.Id,
+ ServiceChannelId = lockedChannel.Id,
+ ServiceChannelName = lockedChannel.ChannelName,
+ ProviderName = lockedChannel.ChannelName ?? lockedChannel.ChannelCode,
+ ChannelCode = lockedChannel.ChannelCode,
+ Weight = 0,
+ CustomerIds = [],
+ MatchCondition = null
+ };
+ if (poolRow == null
+ || AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment(lockedProvider))
+ {
+ await AllocationProviderSnapshotEnricher.EnrichAsync([lockedProvider]);
+ }
+
+ filtered = [lockedProvider];
}
+ else
+ {
+ var failReason = poolRow == null
+ ? "指定渠道不在该销售产品排货池"
+ : "指定渠道不满足判断条件";
+ if (poolRow != null
+ && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
+ {
+ skipped.Add(new OrderAllocationSkippedOutput
+ {
+ ServiceChannelId = forceServiceChannelId.Value,
+ ChannelCode = poolRow.ChannelCode,
+ ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
+ Stage = AllocationResolver.SkipStageCondition,
+ MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
+ Reason = failReason
+ });
+ }
- return BaseEval(failReason);
+ return BaseEval(failReason);
+ }
+ }
+ else
+ {
+ filtered = designated;
}
-
- filtered = designated;
}
if (filtered.Count == 0)
@@ -1304,7 +1341,8 @@ public partial class OrderService
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
- int? forceServiceChannelId = null) =>
+ int? forceServiceChannelId = null,
+ bool allowDesignatedOutsidePool = false) =>
ConfirmServiceChannelAndPayableAsync(
order,
product,
@@ -1314,7 +1352,8 @@ public partial class OrderService
chargingStage,
billing.PriceTierKey,
schemeOverride,
- forceServiceChannelId);
+ forceServiceChannelId,
+ allowDesignatedOutsidePool);
///
/// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。
@@ -1325,7 +1364,8 @@ public partial class OrderService
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
- int? forceServiceChannelId = null)
+ int? forceServiceChannelId = null,
+ bool allowDesignatedOutsidePool = false)
{
try
{
@@ -1335,7 +1375,8 @@ public partial class OrderService
billing,
chargingStage,
schemeOverride,
- forceServiceChannelId);
+ forceServiceChannelId,
+ allowDesignatedOutsidePool);
return true;
}
catch (BusinessException ex)
diff --git a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
index 19eef43..fa7489d 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderFeePushService.cs
@@ -74,15 +74,66 @@ public class PickupOrderFeePushService : IScopedDependency
var pickup = await _pickupOrderRepository.GetByCarrierWaybillNoAsync(carrierWaybillNo)
?? throw new BusinessException($"揽收单不存在:承运运单号 {carrierWaybillNo}");
- if (pickup.PayStatus == PickupPayStatusCodes.Paid)
+ EnsureCanReceiveMeasure(pickup);
+
+ return await ApplyMeasureAndBillAsync(
+ pickup,
+ input.TotalActualWeight,
+ sizes,
+ input.CarrierFreightWeight,
+ input.CarrierQuotedFreightAmount,
+ input.CarrierQuotedReceivableAmount,
+ input.ValueInfoList);
+ }
+
+ ///
+ /// 按已保存(或入参)件重尺,用当前承运服务卖价/买价重算出账。
+ ///
+ public async Task RecalcByPickupAsync(
+ PickupOrder pickup,
+ decimal? totalActualWeightOverride = null,
+ IReadOnlyList? sizesOverride = null)
+ {
+ EnsureCanReceiveMeasure(pickup);
+
+ var weight = totalActualWeightOverride ?? pickup.TotalActualWeight ?? 0;
+ if (weight <= 0)
{
- throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用");
+ throw new BusinessException("请先填写实重后再重算费用");
}
- if (PickupOrderStatusCodes.IsTerminal(pickup.Status)
- && pickup.Status != PickupOrderStatusCodes.Inbound)
+ var sizes = sizesOverride != null && sizesOverride.Count > 0
+ ? NormalizeSizes(sizesOverride.ToList())
+ : NormalizeSizes(
+ OmsDocHelper.DeserializeJson>(pickup.PackageSizesJson));
+ if (sizes.Count == 0)
{
- throw new BusinessException("当前揽收单状态不可接收件重尺");
+ throw new BusinessException("请先填写件重尺尺寸后再重算费用");
+ }
+
+ return await ApplyMeasureAndBillAsync(pickup, weight, sizes);
+ }
+
+ public async Task ApplyMeasureAndBillAsync(
+ PickupOrder pickup,
+ decimal totalActualWeight,
+ IReadOnlyList sizes,
+ decimal? carrierFreightWeight = null,
+ decimal? carrierQuotedFreightAmount = null,
+ decimal? carrierQuotedReceivableAmount = null,
+ List? valueInfoList = null)
+ {
+ if (totalActualWeight <= 0)
+ {
+ throw new BusinessException("totalActualWeight 必须大于 0");
+ }
+
+ var normalizedSizes = sizes is List list
+ ? NormalizeSizes(list)
+ : NormalizeSizes(sizes.ToList());
+ if (normalizedSizes.Count == 0)
+ {
+ throw new BusinessException("packageSizes 不能为空");
}
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
@@ -93,14 +144,14 @@ public class PickupOrderFeePushService : IScopedDependency
var product = await _salesProductRepository.GetByProductCodeAsync(pickup.CarrierService.Trim())
?? throw new BusinessException($"未找到承运服务对应销售产品「{pickup.CarrierService}」");
- var totalVolume = sizes.Sum(x => x.Length * x.Width * x.Height * x.Count);
+ var totalVolume = normalizedSizes.Sum(x => x.Length * x.Width * x.Height * x.Count);
var weightRow = new OrderWeight
{
Type = OrderWeightTypeCodes.Receive,
Length = totalVolume,
Width = 1,
Height = 1,
- ActualWeight = input.TotalActualWeight,
+ ActualWeight = totalActualWeight,
Cubic = ParcelBillingCalculator.CalculateCubic(totalVolume, 1, 1)
};
@@ -187,17 +238,33 @@ public class PickupOrderFeePushService : IScopedDependency
var freight = receivableFees.FirstOrDefault()?.Amount ?? receivableTotal;
var other = Math.Max(0, receivableTotal - freight);
- pickup.TotalActualWeight = input.TotalActualWeight;
+ pickup.TotalActualWeight = totalActualWeight;
pickup.VolumetricWeight = volumetricWeight;
pickup.ChargeableWeight = chargeableWeight;
- pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
- pickup.CarrierFreightWeight = input.CarrierFreightWeight;
- pickup.CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount;
- pickup.CarrierQuotedReceivableAmount = input.CarrierQuotedReceivableAmount;
- pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
+ pickup.PackageSizesJson = OmsDocHelper.SerializeJson(normalizedSizes);
+ if (carrierFreightWeight.HasValue)
{
- ValueInfoList = input.ValueInfoList ?? []
- });
+ pickup.CarrierFreightWeight = carrierFreightWeight;
+ }
+
+ if (carrierQuotedFreightAmount.HasValue)
+ {
+ pickup.CarrierQuotedFreightAmount = carrierQuotedFreightAmount;
+ }
+
+ if (carrierQuotedReceivableAmount.HasValue)
+ {
+ pickup.CarrierQuotedReceivableAmount = carrierQuotedReceivableAmount;
+ }
+
+ if (valueInfoList != null)
+ {
+ pickup.CarrierQuoteJson = OmsDocHelper.SerializeJson(new PickupCarrierQuoteOutput
+ {
+ ValueInfoList = valueInfoList
+ });
+ }
+
pickup.ReceivableFeeJson = OmsDocHelper.SerializeJson(receivableFees);
pickup.PayableFeeJson = OmsDocHelper.SerializeJson(payableFees);
pickup.FreightAmount = freight;
@@ -211,15 +278,29 @@ public class PickupOrderFeePushService : IScopedDependency
return new PickupOpenApiPushFeeResult
{
PickupNo = pickup.PickupNo,
- TotalActualWeight = input.TotalActualWeight,
+ TotalActualWeight = totalActualWeight,
ChargeableWeight = chargeableWeight,
VolumetricWeight = volumetricWeight,
ReceivableAmount = receivableTotal,
CarrierPayableAmount = payableTotal,
- CarrierQuotedFreightAmount = input.CarrierQuotedFreightAmount
+ CarrierQuotedFreightAmount = pickup.CarrierQuotedFreightAmount
};
}
+ private static void EnsureCanReceiveMeasure(PickupOrder pickup)
+ {
+ if (pickup.PayStatus == PickupPayStatusCodes.Paid)
+ {
+ throw new BusinessException("揽收单已支付,拒绝修改件重尺与费用");
+ }
+
+ if (PickupOrderStatusCodes.IsTerminal(pickup.Status)
+ && pickup.Status != PickupOrderStatusCodes.Inbound)
+ {
+ throw new BusinessException("当前揽收单状态不可接收件重尺");
+ }
+ }
+
private async Task<(decimal VolumetricWeight, decimal ChargeableWeight)> CalculateChargeableAsync(
SalesProduct product,
OrderWeight weight)
diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs
index 4ee5162..b6e5d8a 100644
--- a/src/Oms.Application/Services/Orders/PickupOrderService.cs
+++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs
@@ -69,6 +69,9 @@ public class PickupOrderService : ApplicationService
private IPickupBigBagTrackSyncer PickupBigBagTrackSyncer =>
LazyServiceProvider.GetRequiredService();
+ private PickupOrderFeePushService PickupOrderFeePushService =>
+ LazyServiceProvider.GetRequiredService();
+
///
/// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。
///
@@ -274,6 +277,19 @@ public class PickupOrderService : ApplicationService
await OrderRepository.UpdateAsync(order);
}
+ // 已选承运服务时:创建后自动预报下游拿承运运单号(失败不影响建单)
+ if (!string.IsNullOrWhiteSpace(entity.CarrierService))
+ {
+ try
+ {
+ await DownstreamForwardCoreAsync(entity);
+ }
+ catch
+ {
+ // 建单已成功,预报失败由列表「预报下游」重试
+ }
+ }
+
return await MapOutputAsync(entity, includeOrders: true);
}
@@ -551,6 +567,213 @@ public class PickupOrderService : ApplicationService
return await MapOutputAsync(pickup, includeOrders: true);
}
+ ///
+ /// 修改揽收信息(未支付且 draft/scheduled)。
+ ///
+ [HttpPost("/order/pickup-order/update-info")]
+ public async Task UpdateInfoAsync([FromBody] PickupOrderUpdateInfoInput input)
+ {
+ var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
+ EnsureUnpaidEditable(pickup);
+ if (!PickupOrderStatusCodes.IsMutableBinding(pickup.Status))
+ {
+ throw new BusinessException("当前状态不可修改揽收信息");
+ }
+
+ var warehouseChanged = false;
+ var expectTimeChanged = false;
+ var senderChanged = false;
+
+ if (input.WarehouseId is > 0 && input.WarehouseId.Value != pickup.WarehouseId)
+ {
+ var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value);
+ if (warehouse == null)
+ {
+ throw new BusinessException("仓库不存在");
+ }
+
+ pickup.WarehouseId = input.WarehouseId.Value;
+ warehouseChanged = true;
+ }
+
+ if (input.SenderAddressId is > 0 && input.SenderAddressId.Value != pickup.SenderAddressId)
+ {
+ var senderAddress = await CustomerSenderAddressRepository.GetOwnedAsync(
+ input.SenderAddressId.Value,
+ pickup.CustomerId);
+ if (senderAddress == null)
+ {
+ throw new BusinessException("揽收地址不存在");
+ }
+
+ var addressType = string.IsNullOrWhiteSpace(senderAddress.AddressType)
+ ? CustomerAddressTypeCodes.Sender
+ : senderAddress.AddressType;
+ if (!CustomerAddressTypeCodes.IsPickupEligible(addressType))
+ {
+ throw new BusinessException("揽收单请选择「国内揽收」类型地址");
+ }
+
+ string? senderCountryCode = null;
+ if (senderAddress.Country.HasValue)
+ {
+ var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == senderAddress.Country.Value);
+ senderCountryCode = country?.CountryCode;
+ }
+
+ pickup.SenderAddressId = senderAddress.Id;
+ pickup.SenderName = senderAddress.SenderName ?? string.Empty;
+ pickup.SenderCompany = senderAddress.Company;
+ pickup.SenderCountry = senderAddress.Country;
+ pickup.SenderCountryCode = senderCountryCode;
+ pickup.SenderProvince = senderAddress.Province;
+ pickup.SenderCity = senderAddress.City;
+ pickup.SenderZipCode = senderAddress.ZipCode;
+ pickup.SenderStreetAddress1 = senderAddress.StreetAddress1 ?? string.Empty;
+ pickup.SenderStreetAddress2 = senderAddress.StreetAddress2;
+ pickup.SenderStreetAddress3 = senderAddress.StreetAddress3;
+ pickup.SenderHouseNumber = senderAddress.HouseNumber;
+ pickup.SenderPhone = senderAddress.Phone;
+ pickup.SenderTelePhone = senderAddress.TelePhone;
+ pickup.SenderEmail = senderAddress.Email;
+ senderChanged = true;
+ }
+
+ if (input.ExpectPickupTime.HasValue && input.ExpectPickupTime.Value != default)
+ {
+ pickup.ExpectPickupTime = input.ExpectPickupTime.Value;
+ expectTimeChanged = true;
+ }
+
+ if (input.CarrierService != null)
+ {
+ var carrierService = NormalizeOptional(input.CarrierService);
+ if (carrierService == null)
+ {
+ throw new BusinessException("承运服务不能为空");
+ }
+
+ pickup.CarrierService = carrierService;
+ }
+
+ if (input.CarrierWaybillNo != null)
+ {
+ pickup.CarrierWaybillNo = NormalizeOptional(input.CarrierWaybillNo);
+ }
+
+ if (input.Remark != null)
+ {
+ pickup.Remark = NormalizeOptional(input.Remark);
+ }
+
+ // 写入承运运单号后 draft → scheduled(已预约)
+ if (!string.IsNullOrWhiteSpace(pickup.CarrierWaybillNo)
+ && pickup.Status == PickupOrderStatusCodes.Draft)
+ {
+ pickup.Status = PickupOrderStatusCodes.Scheduled;
+ }
+
+ await PickupOrderRepository.UpdateAsync(pickup);
+
+ if (warehouseChanged || expectTimeChanged || senderChanged)
+ {
+ var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id);
+ foreach (var line in lines)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == line.OrderId);
+ if (order == null)
+ {
+ continue;
+ }
+
+ if (warehouseChanged)
+ {
+ order.WarehouseId = pickup.WarehouseId;
+ }
+
+ if (expectTimeChanged)
+ {
+ order.PickupTime = pickup.ExpectPickupTime;
+ }
+
+ if (senderChanged)
+ {
+ order.CustomerSenderAddressId = pickup.SenderAddressId;
+ }
+
+ await OrderRepository.UpdateAsync(order);
+ }
+ }
+
+ return await MapOutputAsync(pickup, includeOrders: true);
+ }
+
+ ///
+ /// 修改件重尺(未支付;仅落库,不改费用)。
+ ///
+ [HttpPost("/order/pickup-order/update-measure")]
+ public async Task UpdateMeasureAsync([FromBody] PickupOrderUpdateMeasureInput input)
+ {
+ var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
+ EnsureUnpaidEditable(pickup);
+ if (PickupOrderStatusCodes.IsTerminal(pickup.Status)
+ && pickup.Status != PickupOrderStatusCodes.Inbound)
+ {
+ throw new BusinessException("当前状态不可修改件重尺");
+ }
+
+ if (input.TotalActualWeight <= 0)
+ {
+ throw new BusinessException("实重必须大于 0");
+ }
+
+ var sizes = (input.PackageSizes ?? [])
+ .Select(x => new PickupPackageSizeOutput
+ {
+ Length = x.Length,
+ Width = x.Width,
+ Height = x.Height,
+ Count = x.Count <= 0 ? 1 : x.Count
+ })
+ .Where(x => x.Length > 0 && x.Width > 0 && x.Height > 0)
+ .ToList();
+ if (sizes.Count == 0)
+ {
+ throw new BusinessException("请至少填写一条有效尺寸(长宽高)");
+ }
+
+ pickup.TotalActualWeight = input.TotalActualWeight;
+ pickup.PackageSizesJson = OmsDocHelper.SerializeJson(sizes);
+ pickup.UpdateTime = DateTime.Now;
+ await PickupOrderRepository.UpdateAsync(pickup);
+ return await MapOutputAsync(pickup, includeOrders: true);
+ }
+
+ ///
+ /// 按当前件重尺与承运服务重算费用。
+ ///
+ [HttpPost("/order/pickup-order/recalc-fee")]
+ public async Task RecalcFeeAsync([FromBody] PickupOrderIdInput input)
+ {
+ var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
+ EnsureUnpaidEditable(pickup);
+ await PickupOrderFeePushService.RecalcByPickupAsync(pickup);
+ return await MapOutputAsync(pickup, includeOrders: true);
+ }
+
+ private static void EnsureUnpaidEditable(PickupOrder pickup)
+ {
+ if (pickup.PayStatus == PickupPayStatusCodes.Paid)
+ {
+ throw new BusinessException("已支付不可修改");
+ }
+
+ if (pickup.Status is PickupOrderStatusCodes.Cancelled or PickupOrderStatusCodes.Failed)
+ {
+ throw new BusinessException("当前状态不可修改");
+ }
+ }
+
///
/// 客户支付揽收运费(余额)。
///
@@ -930,6 +1153,14 @@ public class PickupOrderService : ApplicationService
public async Task DownstreamForwardAsync([FromBody] PickupOrderIdInput input)
{
var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
+ return await DownstreamForwardCoreAsync(pickup);
+ }
+
+ ///
+ /// 预报下游核心:回写承运运单号,draft 升为 scheduled。
+ ///
+ private async Task DownstreamForwardCoreAsync(PickupOrder pickup)
+ {
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
{
throw new BusinessException("请先选择承运服务后再预报下游");
@@ -974,6 +1205,14 @@ public class PickupOrderService : ApplicationService
changed = true;
}
+ // 拿到承运运单号后 draft → scheduled(已预约)
+ if (!string.IsNullOrWhiteSpace(pickup.CarrierWaybillNo)
+ && pickup.Status == PickupOrderStatusCodes.Draft)
+ {
+ pickup.Status = PickupOrderStatusCodes.Scheduled;
+ changed = true;
+ }
+
if (string.IsNullOrWhiteSpace(pickup.CarrierCode)
&& !string.IsNullOrWhiteSpace(creds.SupplierCode))
{
diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs
index 8abb5fb..46a3439 100644
--- a/src/Oms.Application/Services/Rbac/OmsUserService.cs
+++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs
@@ -5,11 +5,13 @@ using Fake.Rbac.Application.Dtos.Role;
using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Services;
using Fake.Rbac.Domain.Managers;
+using Fake.Rbac.Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Fake.ObjectStorage;
using Oms.Application.Dtos.Rbac;
using Oms.Application.Rbac;
+using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Enums;
@@ -27,6 +29,7 @@ public class OmsUserService(
AccountManager accountManager,
UserProfileRepository profileRepository,
IEmployeeRepository employeeRepository,
+ FakeRbacDbContext rbacDbContext,
IObjectStorage fileStorage) : ApplicationService
{
///
@@ -94,34 +97,78 @@ public class OmsUserService(
[FromBody] OmsUserCreateInput input,
CancellationToken cancellationToken = default)
{
- var employeeNo = await ResolveEmployeeNoForCreateAsync(input.EmployeeNo, cancellationToken);
- var user = await userService.CreateAsync(new UserCreateDto
- {
- Name = input.Name,
- Account = input.Account,
- Password = input.Password,
- Email = input.Email,
- Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
- OrganizationId = input.OrganizationId,
- RoleIds = input.RoleIds,
- }, cancellationToken);
+ var account = input.Account?.Trim() ?? string.Empty;
+ OmsRbacRegistrationGuard.ValidateAdminUserFields(
+ input.Name,
+ account,
+ input.Password,
+ input.Email,
+ input.Phone,
+ input.WechatId,
+ requirePassword: true);
- await profileRepository.SaveAsync(
- user.Id,
- new UserProfileData
- {
- Phone = input.Phone,
- EmployeeNo = employeeNo,
- WechatId = input.WechatId,
- WechatQrCode = input.WechatQrCode,
- WecomKfUrl = input.WecomKfUrl,
- },
+ var employeeNo = await ResolveEmployeeNoForCreateAsync(input.EmployeeNo, cancellationToken);
+ await OmsRbacRegistrationGuard.EnsureLoginIdentityAvailableAsync(
+ rbacDbContext,
+ account,
+ input.Email,
+ forAdminUserCreate: true,
+ excludeUserId: null,
+ cancellationToken);
+ await OmsRbacRegistrationGuard.EnsurePhoneAvailableAsync(
+ rbacDbContext,
+ input.Phone,
+ excludeUserId: null,
+ cancellationToken);
+ await OmsRbacRegistrationGuard.EnsureWechatIdAvailableAsync(
+ rbacDbContext,
+ input.WechatId,
+ excludeUserId: null,
+ cancellationToken);
+ await OmsRbacRegistrationGuard.EnsureOrganizationExistsAsync(
+ rbacDbContext,
+ input.OrganizationId,
+ cancellationToken);
+ await OmsRbacRegistrationGuard.EnsureRolesExistAsync(
+ rbacDbContext,
+ input.RoleIds,
cancellationToken);
- await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken);
+ try
+ {
+ var user = await userService.CreateAsync(new UserCreateDto
+ {
+ Name = input.Name!.Trim(),
+ Account = account,
+ Password = input.Password,
+ // 禁止 Email='':uk_user_email 会把空串当成占用
+ Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(),
+ Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
+ OrganizationId = input.OrganizationId,
+ RoleIds = input.RoleIds,
+ }, cancellationToken);
- var profile = await profileRepository.GetAsync(user.Id, cancellationToken);
- return MapUserDetail(user, profile);
+ await profileRepository.SaveAsync(
+ user.Id,
+ new UserProfileData
+ {
+ Phone = input.Phone,
+ EmployeeNo = employeeNo,
+ WechatId = input.WechatId,
+ WechatQrCode = input.WechatQrCode,
+ WecomKfUrl = input.WecomKfUrl,
+ },
+ cancellationToken);
+
+ await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken);
+
+ var profile = await profileRepository.GetAsync(user.Id, cancellationToken);
+ return MapUserDetail(user, profile);
+ }
+ catch (Exception ex) when (ex is not BusinessException)
+ {
+ throw OmsRbacRegistrationGuard.ToSaveBusinessException(ex, forAdminUserCreate: true);
+ }
}
///
@@ -133,30 +180,75 @@ public class OmsUserService(
[FromBody] OmsUserUpdateInput input,
CancellationToken cancellationToken = default)
{
- var user = await userService.UpdateAsync(
- id,
- new UserUpdateDto
- {
- Name = input.Name,
- Email = input.Email,
- Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
- OrganizationId = input.OrganizationId,
- },
+ if (id == Guid.Empty)
+ {
+ throw new BusinessException("用户 Id 无效");
+ }
+
+ // 更新不改登录账号/密码
+ OmsRbacRegistrationGuard.ValidateAdminUserFields(
+ input.Name,
+ account: "placeholder",
+ password: null,
+ input.Email,
+ input.Phone,
+ input.WechatId,
+ requirePassword: false);
+
+ var email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim();
+ await OmsRbacRegistrationGuard.EnsureEmailAvailableAsync(
+ rbacDbContext,
+ email,
+ excludeUserId: id,
+ forAdminUserCreate: true,
cancellationToken);
- await profileRepository.UpdateContactAsync(
- id,
- new UserProfileData
- {
- Phone = input.Phone,
- WechatId = input.WechatId,
- WechatQrCode = input.WechatQrCode,
- WecomKfUrl = input.WecomKfUrl,
- },
+ await OmsRbacRegistrationGuard.EnsurePhoneAvailableAsync(
+ rbacDbContext,
+ input.Phone,
+ excludeUserId: id,
+ cancellationToken);
+ await OmsRbacRegistrationGuard.EnsureWechatIdAvailableAsync(
+ rbacDbContext,
+ input.WechatId,
+ excludeUserId: id,
+ cancellationToken);
+ await OmsRbacRegistrationGuard.EnsureOrganizationExistsAsync(
+ rbacDbContext,
+ input.OrganizationId,
cancellationToken);
- var profile = await profileRepository.GetAsync(id, cancellationToken);
- return MapUserDetail(user, profile);
+ try
+ {
+ var user = await userService.UpdateAsync(
+ id,
+ new UserUpdateDto
+ {
+ Name = input.Name!.Trim(),
+ Email = email,
+ Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar),
+ OrganizationId = input.OrganizationId,
+ },
+ cancellationToken);
+
+ await profileRepository.UpdateContactAsync(
+ id,
+ new UserProfileData
+ {
+ Phone = input.Phone,
+ WechatId = input.WechatId,
+ WechatQrCode = input.WechatQrCode,
+ WecomKfUrl = input.WecomKfUrl,
+ },
+ cancellationToken);
+
+ var profile = await profileRepository.GetAsync(id, cancellationToken);
+ return MapUserDetail(user, profile);
+ }
+ catch (Exception ex) when (ex is not BusinessException)
+ {
+ throw OmsRbacRegistrationGuard.ToSaveBusinessException(ex, forAdminUserCreate: true);
+ }
}
///
@@ -259,12 +351,12 @@ public class OmsUserService(
if (!UserProfileRepository.IsValidEmployeeNo(employeeNo))
{
- throw new BusinessException("工号格式无效,应为 IDE + 至少 3 位数字,如 IDE001");
+ throw new BusinessException($"工号「{employeeNo}」格式无效,应为 IDE + 至少 3 位数字,如 IDE001");
}
if (await profileRepository.IsEmployeeNoTakenAsync(employeeNo, null, cancellationToken))
{
- throw new BusinessException($"工号已存在:{employeeNo}");
+ throw new BusinessException($"工号「{employeeNo}」已存在,请更换工号后再创建");
}
return employeeNo;
diff --git a/src/Oms.Application/Services/System/WarehouseService.cs b/src/Oms.Application/Services/System/WarehouseService.cs
index 8532769..64dc16f 100644
--- a/src/Oms.Application/Services/System/WarehouseService.cs
+++ b/src/Oms.Application/Services/System/WarehouseService.cs
@@ -33,13 +33,16 @@ public class WarehouseService : ApplicationService
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
- var entities = await WarehouseAddressRepository.GetPagedListAsync(
+ // 有 Sort 的按升序靠前;无 Sort 的靠后;同序再按 Id
+ var (entities, totalCount) = await RepositoryPageHelper.GetPageAsync(
+ WarehouseAddressRepository,
predicate,
pageIndex,
pageSize,
- new Dictionary { [nameof(WarehouseAddress.UpdateTime)] = false, [nameof(WarehouseAddress.Id)] = false });
+ q => q.OrderBy(x => x.Sort == null)
+ .ThenBy(x => x.Sort)
+ .ThenBy(x => x.Id));
- var totalCount = await WarehouseAddressRepository.CountAsync(predicate);
var items = entities.Select(entity =>
{
var output = ObjectMapper.Map(entity);
@@ -97,6 +100,8 @@ public class WarehouseService : ApplicationService
deleted.Recipient = input.Recipient;
deleted.ContactPhone = input.ContactPhone;
deleted.ShippingAddress = input.ShippingAddress;
+ deleted.Sort = input.Sort;
+ deleted.Remark = NormalizeRemark(input.Remark);
await WarehouseAddressRepository.UpdateAsync(deleted);
return;
}
@@ -108,6 +113,8 @@ public class WarehouseService : ApplicationService
Recipient = input.Recipient,
ContactPhone = input.ContactPhone,
ShippingAddress = input.ShippingAddress,
+ Sort = input.Sort,
+ Remark = NormalizeRemark(input.Remark),
};
await WarehouseAddressRepository.InsertAsync(entity);
@@ -141,6 +148,8 @@ public class WarehouseService : ApplicationService
entity.Recipient = input.Recipient;
entity.ContactPhone = input.ContactPhone;
entity.ShippingAddress = input.ShippingAddress;
+ entity.Sort = input.Sort;
+ entity.Remark = NormalizeRemark(input.Remark);
await WarehouseAddressRepository.UpdateAsync(entity);
}
@@ -180,4 +189,10 @@ public class WarehouseService : ApplicationService
return entity;
}
+
+ private static string? NormalizeRemark(string? remark)
+ {
+ var value = remark?.Trim();
+ return string.IsNullOrEmpty(value) ? null : value;
+ }
}
diff --git a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
index afb1ef4..4073363 100644
--- a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
+++ b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs
@@ -108,6 +108,9 @@ public class AllocationProviderSnapshotEnricher(
ServiceChannel? channelFallback = null)
{
order.DownstreamSupplierCode = NullIfEmpty(provider.SupplierCode);
+ order.DownstreamSupplierName = NullIfEmpty(provider.ApiServiceProviderName)
+ ?? NullIfEmpty(provider.GroupName)
+ ?? NullIfEmpty(provider.ProviderName);
order.DownstreamAppKey = NullIfEmpty(provider.AppKey);
order.DownstreamAppId = provider.AppId;
order.ChannelForecastCode = NullIfEmpty(provider.ForecastCode)
diff --git a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs
index e726af9..6241a72 100644
--- a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs
+++ b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs
@@ -1,3 +1,5 @@
+using System.Net.Mail;
+using System.Text.RegularExpressions;
using Fake.Application;
using Fake.Rbac.Infrastructure;
using Microsoft.EntityFrameworkCore;
@@ -5,32 +7,276 @@ using Microsoft.EntityFrameworkCore;
namespace Oms.Application.Shared;
///
-/// 微信/客户注册前校验 RBAC 登录账号,避免 user 表已存在但 t_customer 未创建时 EF 抛出英文 DbUpdateException。
+/// 微信/客户注册、后台建用户前校验 RBAC 登录身份,避免 EF 抛出英文 DbUpdateException。
///
public static class OmsRbacRegistrationGuard
{
+ private static readonly Regex PhonePattern = new(@"^1\d{10}$", RegexOptions.Compiled);
+
public static async Task EnsureLoginIdentityAvailableAsync(
FakeRbacDbContext rbacDbContext,
string account,
string? email,
CancellationToken cancellationToken = default)
+ {
+ await EnsureLoginIdentityAvailableAsync(
+ rbacDbContext,
+ account,
+ email,
+ forAdminUserCreate: false,
+ excludeUserId: null,
+ cancellationToken);
+ }
+
+ ///
+ /// 校验登录账号/邮箱是否可用。
+ /// 为 true 时提示面向后台建员工用户(会说明可能已被客户账号占用)。
+ ///
+ public static async Task EnsureLoginIdentityAvailableAsync(
+ FakeRbacDbContext rbacDbContext,
+ string account,
+ string? email,
+ bool forAdminUserCreate,
+ CancellationToken cancellationToken = default)
+ {
+ await EnsureLoginIdentityAvailableAsync(
+ rbacDbContext,
+ account,
+ email,
+ forAdminUserCreate,
+ excludeUserId: null,
+ cancellationToken);
+ }
+
+ public static async Task EnsureLoginIdentityAvailableAsync(
+ FakeRbacDbContext rbacDbContext,
+ string account,
+ string? email,
+ bool forAdminUserCreate,
+ Guid? excludeUserId,
+ CancellationToken cancellationToken = default)
{
var normalizedAccount = account.Trim();
var normalizedEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
- if (await ExistsActiveUserByAccountAsync(rbacDbContext, normalizedAccount, cancellationToken))
+ if (await ExistsUserByAccountAsync(rbacDbContext, normalizedAccount, excludeUserId, cancellationToken))
{
- throw new BusinessException("登录账号已存在,请直接绑定或使用其他账号");
+ throw new BusinessException(
+ forAdminUserCreate
+ ? $"登录账号「{normalizedAccount}」已存在。客户门户注册也会占用同一登录账号,请更换账号后再创建员工用户"
+ : "登录账号已存在,请直接绑定或使用其他账号");
}
if (normalizedEmail != null
- && await ExistsActiveUserByEmailAsync(rbacDbContext, normalizedEmail, cancellationToken))
+ && await ExistsUserByEmailAsync(rbacDbContext, normalizedEmail, excludeUserId, cancellationToken))
{
- throw new BusinessException("邮箱已被占用,请更换邮箱或直接绑定已有账号");
+ throw new BusinessException(
+ forAdminUserCreate
+ ? $"邮箱「{normalizedEmail}」已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再创建员工用户"
+ : "邮箱已被占用,请更换邮箱或直接绑定已有账号");
}
}
- public static BusinessException ToSaveBusinessException(Exception ex)
+ ///
+ /// 后台创建/更新员工用户时的字段格式与必填校验(不含唯一性)。
+ ///
+ public static void ValidateAdminUserFields(
+ string? name,
+ string? account,
+ string? password,
+ string? email,
+ string? phone,
+ string? wechatId,
+ bool requirePassword)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new BusinessException("姓名不能为空");
+ }
+
+ if (name.Trim().Length > 64)
+ {
+ throw new BusinessException($"姓名过长(当前 {name.Trim().Length} 字,最多 64 字)");
+ }
+
+ if (string.IsNullOrWhiteSpace(account))
+ {
+ throw new BusinessException("登录账号不能为空");
+ }
+
+ if (account.Trim().Length > 64)
+ {
+ throw new BusinessException($"登录账号过长(当前 {account.Trim().Length} 字,最多 64 字)");
+ }
+
+ if (requirePassword)
+ {
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ throw new BusinessException("密码不能为空");
+ }
+
+ if (password.Length < 6)
+ {
+ throw new BusinessException("密码长度不能少于6位");
+ }
+
+ if (password.Length > 128)
+ {
+ throw new BusinessException("密码过长,请控制在 128 位以内");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(email))
+ {
+ var trimmedEmail = email.Trim();
+ if (trimmedEmail.Length > 128)
+ {
+ throw new BusinessException($"邮箱过长(当前 {trimmedEmail.Length} 字,最多 128 字)");
+ }
+
+ if (!IsValidEmail(trimmedEmail))
+ {
+ throw new BusinessException($"邮箱格式不正确:{trimmedEmail}");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(phone))
+ {
+ var trimmedPhone = phone.Trim();
+ if (!PhonePattern.IsMatch(trimmedPhone))
+ {
+ throw new BusinessException($"手机号格式不正确:{trimmedPhone}(应为 11 位中国大陆手机号)");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(wechatId) && wechatId.Trim().Length > 64)
+ {
+ throw new BusinessException($"微信号过长(当前 {wechatId.Trim().Length} 字,最多 64 字)");
+ }
+ }
+
+ public static async Task EnsureOrganizationExistsAsync(
+ FakeRbacDbContext rbacDbContext,
+ Guid? organizationId,
+ CancellationToken cancellationToken = default)
+ {
+ if (organizationId is null || organizationId == Guid.Empty)
+ {
+ return;
+ }
+
+ var hit = await rbacDbContext.Database
+ .SqlQuery(
+ $"""
+ SELECT 1 AS Value
+ FROM `organization`
+ WHERE `Id` = {organizationId.Value.ToString()} AND `IsDeleted` = 0
+ LIMIT 1
+ """)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (!hit.HasValue)
+ {
+ throw new BusinessException($"所属组织不存在或已删除({organizationId}),请重新选择组织");
+ }
+ }
+
+ public static async Task EnsureRolesExistAsync(
+ FakeRbacDbContext rbacDbContext,
+ IEnumerable? roleIds,
+ CancellationToken cancellationToken = default)
+ {
+ if (roleIds == null)
+ {
+ return;
+ }
+
+ var ids = roleIds.Where(x => x != Guid.Empty).Distinct().ToList();
+ if (ids.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var roleId in ids)
+ {
+ var hit = await rbacDbContext.Database
+ .SqlQuery(
+ $"""
+ SELECT 1 AS Value
+ FROM `role`
+ WHERE `Id` = {roleId.ToString()} AND `IsDeleted` = 0
+ LIMIT 1
+ """)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (!hit.HasValue)
+ {
+ throw new BusinessException($"角色不存在或已删除({roleId}),请重新选择角色");
+ }
+ }
+ }
+
+ public static async Task EnsureEmailAvailableAsync(
+ FakeRbacDbContext rbacDbContext,
+ string? email,
+ Guid? excludeUserId,
+ bool forAdminUserCreate,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(email))
+ {
+ return;
+ }
+
+ var normalizedEmail = email.Trim();
+ if (await ExistsUserByEmailAsync(rbacDbContext, normalizedEmail, excludeUserId, cancellationToken))
+ {
+ throw new BusinessException(
+ forAdminUserCreate
+ ? $"邮箱「{normalizedEmail}」已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再保存"
+ : "邮箱已被占用,请更换邮箱或直接绑定已有账号");
+ }
+ }
+
+ public static async Task EnsurePhoneAvailableAsync(
+ FakeRbacDbContext rbacDbContext,
+ string? phone,
+ Guid? excludeUserId,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(phone))
+ {
+ return;
+ }
+
+ var normalized = phone.Trim();
+ if (await ExistsUserByPhoneAsync(rbacDbContext, normalized, excludeUserId, cancellationToken))
+ {
+ throw new BusinessException(
+ $"手机号「{normalized}」已被占用。客户门户注册也会占用同一手机号,请更换手机号后再保存");
+ }
+ }
+
+ public static async Task EnsureWechatIdAvailableAsync(
+ FakeRbacDbContext rbacDbContext,
+ string? wechatId,
+ Guid? excludeUserId,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(wechatId))
+ {
+ return;
+ }
+
+ var normalized = wechatId.Trim();
+ if (await ExistsUserByWechatIdAsync(rbacDbContext, normalized, excludeUserId, cancellationToken))
+ {
+ throw new BusinessException($"微信号「{normalized}」已被占用,请更换后再保存");
+ }
+ }
+
+ public static BusinessException ToSaveBusinessException(Exception ex, bool forAdminUserCreate = false)
{
var message = ExtractDeepMessage(ex);
if (ContainsDuplicateKey(message))
@@ -39,19 +285,41 @@ public static class OmsRbacRegistrationGuard
|| message.Contains("uk_user_account", StringComparison.OrdinalIgnoreCase)
|| message.Contains("IX_user_Account", StringComparison.OrdinalIgnoreCase))
{
- return new BusinessException("登录账号已存在,请直接绑定或使用其他账号");
+ return new BusinessException(
+ forAdminUserCreate
+ ? "登录账号已存在。客户门户注册也会占用同一登录账号,请更换账号后再创建员工用户"
+ : "登录账号已存在,请直接绑定或使用其他账号");
}
if (message.Contains("Email", StringComparison.OrdinalIgnoreCase)
|| message.Contains("uk_user_email", StringComparison.OrdinalIgnoreCase))
{
- return new BusinessException("邮箱已被占用,请更换邮箱或直接绑定已有账号");
+ return new BusinessException(
+ forAdminUserCreate
+ ? "邮箱已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再创建员工用户"
+ : "邮箱已被占用,请更换邮箱或直接绑定已有账号");
}
if (message.Contains("Phone", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("uk_user_phone", StringComparison.OrdinalIgnoreCase)
|| message.Contains("uk_cus_phone", StringComparison.OrdinalIgnoreCase))
{
- return new BusinessException("手机号已存在");
+ return new BusinessException(
+ forAdminUserCreate
+ ? "手机号已被占用。客户门户注册也会占用同一手机号,请更换手机号后再保存"
+ : "手机号已存在");
+ }
+
+ if (message.Contains("EmployeeNo", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("uk_user_employee", StringComparison.OrdinalIgnoreCase))
+ {
+ return new BusinessException("工号已被占用,请更换工号后再保存");
+ }
+
+ if (message.Contains("WechatId", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("uk_user_wechat", StringComparison.OrdinalIgnoreCase))
+ {
+ return new BusinessException("微信号已被占用,请更换后再保存");
}
if (message.Contains("uk_cus_email", StringComparison.OrdinalIgnoreCase))
@@ -66,41 +334,125 @@ public static class OmsRbacRegistrationGuard
return new BusinessException("当前微信已绑定账号");
}
- return new BusinessException("账号信息已存在,请直接绑定或更换手机号/邮箱");
+ return new BusinessException(
+ forAdminUserCreate
+ ? "账号信息与已有用户冲突(登录账号/邮箱/手机号等唯一字段),请检查后更换"
+ : "账号信息已存在,请直接绑定或更换手机号/邮箱");
}
if (message.Contains("Data too long", StringComparison.OrdinalIgnoreCase)
|| message.Contains("1406", StringComparison.OrdinalIgnoreCase))
{
- if (message.Contains("Avatar", StringComparison.OrdinalIgnoreCase))
+ var field = ResolveTooLongField(message, forAdminUserCreate);
+ if (field != null)
{
- return new BusinessException("微信头像地址过长,请稍后重试或联系客服");
+ return new BusinessException(field);
}
- if (message.Contains("Nickname", StringComparison.OrdinalIgnoreCase)
- || message.Contains("CustomerName", StringComparison.OrdinalIgnoreCase)
- || message.Contains("Name", StringComparison.OrdinalIgnoreCase))
- {
- return new BusinessException("客户名称或微信昵称过长,请修改后重试");
- }
-
- return new BusinessException("注册信息字段过长,请缩短客户名称后重试");
+ return new BusinessException(
+ forAdminUserCreate
+ ? "保存失败:某个字段内容过长,请缩短后重试"
+ : "注册信息字段过长,请缩短客户名称后重试");
}
if (message.Contains("foreign key constraint fails", StringComparison.OrdinalIgnoreCase)
|| message.Contains("1452", StringComparison.OrdinalIgnoreCase))
{
- return new BusinessException("客户角色未正确初始化,请联系管理员");
+ if (message.Contains("organization", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("Organization", StringComparison.OrdinalIgnoreCase))
+ {
+ return new BusinessException("所属组织不存在或已删除,请重新选择组织");
+ }
+
+ if (message.Contains("role", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("Role", StringComparison.OrdinalIgnoreCase))
+ {
+ return new BusinessException("角色不存在或已删除,请重新选择角色");
+ }
+
+ return new BusinessException("关联数据无效(组织/角色),请检查后重试");
}
if (message.Contains("Cannot insert duplicate key", StringComparison.OrdinalIgnoreCase)
|| message.Contains("2627", StringComparison.OrdinalIgnoreCase))
{
- return new BusinessException("账号信息已存在,请直接绑定或更换手机号/邮箱");
+ return new BusinessException(
+ forAdminUserCreate
+ ? "账号信息与已有用户冲突(登录账号/邮箱/手机号等唯一字段),请检查后更换"
+ : "账号信息已存在,请直接绑定或更换手机号/邮箱");
}
var reason = ExtractActionableReason(ex);
- return new BusinessException($"注册失败:{reason}");
+ return new BusinessException(
+ forAdminUserCreate ? $"保存用户失败:{reason}" : $"注册失败:{reason}");
+ }
+
+ private static string? ResolveTooLongField(string message, bool forAdminUserCreate)
+ {
+ if (message.Contains("Avatar", StringComparison.OrdinalIgnoreCase))
+ {
+ return forAdminUserCreate ? "头像地址过长,请更换后重试" : "微信头像地址过长,请稍后重试或联系客服";
+ }
+
+ if (message.Contains("Account", StringComparison.OrdinalIgnoreCase))
+ {
+ return "登录账号过长,请缩短后重试";
+ }
+
+ if (message.Contains("Email", StringComparison.OrdinalIgnoreCase))
+ {
+ return "邮箱过长,请缩短后重试";
+ }
+
+ if (message.Contains("Phone", StringComparison.OrdinalIgnoreCase))
+ {
+ return "手机号过长,请检查后重试";
+ }
+
+ if (message.Contains("EmployeeNo", StringComparison.OrdinalIgnoreCase))
+ {
+ return "工号过长,请检查后重试";
+ }
+
+ if (message.Contains("WechatId", StringComparison.OrdinalIgnoreCase))
+ {
+ return "微信号过长,请缩短后重试";
+ }
+
+ if (message.Contains("WechatQrCode", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("WecomKfUrl", StringComparison.OrdinalIgnoreCase))
+ {
+ return "微信二维码或客服链接过长,请缩短后重试";
+ }
+
+ if (message.Contains("Password", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("PasswordHash", StringComparison.OrdinalIgnoreCase))
+ {
+ return "密码相关字段过长,请缩短密码后重试";
+ }
+
+ if (message.Contains("Nickname", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("CustomerName", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("`Name`", StringComparison.OrdinalIgnoreCase)
+ || message.Contains(" column 'Name'", StringComparison.OrdinalIgnoreCase))
+ {
+ return forAdminUserCreate ? "姓名过长,请缩短后重试" : "客户名称或微信昵称过长,请修改后重试";
+ }
+
+ return null;
+ }
+
+ private static bool IsValidEmail(string email)
+ {
+ try
+ {
+ var addr = new MailAddress(email);
+ return string.Equals(addr.Address, email, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
}
private static string ExtractActionableReason(Exception ex)
@@ -130,29 +482,93 @@ public static class OmsRbacRegistrationGuard
return reason.Length <= 160 ? reason : reason[..160];
}
- private static async Task ExistsActiveUserByAccountAsync(
+ private static async Task ExistsUserByAccountAsync(
FakeRbacDbContext rbacDbContext,
string account,
+ Guid? excludeUserId,
CancellationToken cancellationToken)
{
// 唯一索引不按 IsDeleted 区分,软删除账号仍会触发 Duplicate entry。
- var hit = await rbacDbContext.Database
+ if (excludeUserId is Guid id && id != Guid.Empty)
+ {
+ var hit = await rbacDbContext.Database
+ .SqlQuery(
+ $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} AND `Id` <> {id.ToString()} LIMIT 1")
+ .FirstOrDefaultAsync(cancellationToken);
+ return hit.HasValue;
+ }
+
+ var any = await rbacDbContext.Database
.SqlQuery(
$"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} LIMIT 1")
.FirstOrDefaultAsync(cancellationToken);
- return hit.HasValue;
+ return any.HasValue;
}
- private static async Task ExistsActiveUserByEmailAsync(
+ private static async Task ExistsUserByEmailAsync(
FakeRbacDbContext rbacDbContext,
string email,
+ Guid? excludeUserId,
CancellationToken cancellationToken)
{
- var hit = await rbacDbContext.Database
+ if (excludeUserId is Guid id && id != Guid.Empty)
+ {
+ var hit = await rbacDbContext.Database
+ .SqlQuery(
+ $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} AND `Id` <> {id.ToString()} LIMIT 1")
+ .FirstOrDefaultAsync(cancellationToken);
+ return hit.HasValue;
+ }
+
+ var any = await rbacDbContext.Database
.SqlQuery(
$"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} LIMIT 1")
.FirstOrDefaultAsync(cancellationToken);
- return hit.HasValue;
+ return any.HasValue;
+ }
+
+ private static async Task ExistsUserByPhoneAsync(
+ FakeRbacDbContext rbacDbContext,
+ string phone,
+ Guid? excludeUserId,
+ CancellationToken cancellationToken)
+ {
+ if (excludeUserId is Guid id && id != Guid.Empty)
+ {
+ var hit = await rbacDbContext.Database
+ .SqlQuery(
+ $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} AND `Id` <> {id.ToString()} LIMIT 1")
+ .FirstOrDefaultAsync(cancellationToken);
+ return hit.HasValue;
+ }
+
+ var any = await rbacDbContext.Database
+ .SqlQuery(
+ $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} LIMIT 1")
+ .FirstOrDefaultAsync(cancellationToken);
+ return any.HasValue;
+ }
+
+ private static async Task ExistsUserByWechatIdAsync(
+ FakeRbacDbContext rbacDbContext,
+ string wechatId,
+ Guid? excludeUserId,
+ CancellationToken cancellationToken)
+ {
+ if (excludeUserId is Guid id && id != Guid.Empty)
+ {
+ var hit = await rbacDbContext.Database
+ .SqlQuery(
+ $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} AND `Id` <> {id.ToString()} LIMIT 1")
+ .FirstOrDefaultAsync(cancellationToken);
+ return hit.HasValue;
+ }
+
+ var any = await rbacDbContext.Database
+ .SqlQuery(
+ $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} LIMIT 1")
+ .FirstOrDefaultAsync(cancellationToken);
+ return any.HasValue;
}
private static string ExtractDeepMessage(Exception ex)
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs
new file mode 100644
index 0000000..17d6f8b
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs
@@ -0,0 +1,22 @@
+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 static string GetActionName(string action) => action switch
+ {
+ EditBasic => "编辑订单详情",
+ EditWeight => "修改件重尺",
+ RecalcReceivable => "重算应收",
+ RecalcPayable => "重算应付",
+ Reallocate => "重新排货",
+ BindSnapshotVersions => "切换价格/规则版本",
+ _ => action
+ };
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index 3aea651..2f2b77e 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -29,6 +29,12 @@ public class Order : OmsAuditedAggregateRoot
///
public string? DownstreamSupplierCode { get; set; }
+ ///
+ /// 下游 API 服务商名称(itfx service provider name)。
+ ///
+ [SugarColumn(Length = 128, IsNullable = true)]
+ public string? DownstreamSupplierName { get; set; }
+
///
/// 下游 itfx 应用 AppKey。
///
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs
new file mode 100644
index 0000000..703354b
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs
@@ -0,0 +1,36 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.OrdersAggregate.Entities;
+
+[SugarTable("t_order_operation_log")]
+public class OrderOperationLog : OmsAuditedAggregateRoot
+{
+ public int OrderId { get; set; }
+
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? ServiceNo { get; set; }
+
+ [SugarColumn(Length = 32)]
+ public string Action { get; set; } = string.Empty;
+
+ [SugarColumn(Length = 64)]
+ public string ActionName { get; set; } = string.Empty;
+
+ public int OperatorId { get; set; }
+
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? OperatorName { get; set; }
+
+ [SugarColumn(Length = 16)]
+ public string OperatorType { get; set; } = "staff";
+
+ [SugarColumn(Length = 16)]
+ public string Source { get; set; } = "pc";
+
+ [SugarColumn(ColumnDataType = "json", IsNullable = true)]
+ public string? ChangesJson { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs
new file mode 100644
index 0000000..b1321bf
--- /dev/null
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs
@@ -0,0 +1,8 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Domain.OrdersAggregate.Repositories;
+
+public interface IOrderOperationLogRepository : IRepository
+{
+}
diff --git a/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs b/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs
index c9d981e..91d436d 100644
--- a/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs
+++ b/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs
@@ -15,4 +15,16 @@ public class WarehouseAddress : OmsAuditedAggregateRoot
public string? ContactPhone { get; set; }
public string? ShippingAddress { get; set; }
+
+ ///
+ /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。
+ ///
+ [SugarColumn(IsNullable = true)]
+ public int? Sort { get; set; }
+
+ ///
+ /// 备注(选填,展示用)。
+ ///
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
}
diff --git a/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs
new file mode 100644
index 0000000..c020ea9
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs
@@ -0,0 +1,13 @@
+using Fake.DependencyInjection;
+using Oms.Domain.OrdersAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Repositories;
+using Oms.SqlSugarCore.Repositories;
+
+namespace Oms.SqlSugarCore.Orders;
+
+public class OrderOperationLogRepository
+ : OmsSqlSugarRepository,
+ IOrderOperationLogRepository,
+ IScopedDependency
+{
+}