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