From 2b0158acb4976864525a63508f8f0a79719c8232 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 9 Oct 2026 17:32:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=90=E5=8D=95=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=20MVP=EF=BC=8C=E8=AE=B0=E5=BD=95=E7=BC=96?= =?UTF-8?q?=E8=BE=91/=E4=BB=B6=E9=87=8D=E5=B0=BA/=E9=87=8D=E7=AE=97/?= =?UTF-8?q?=E6=8E=92=E8=B4=A7=E5=AD=97=E6=AE=B5=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 24 ++ sql/drop_all_unique_indexes.sql | 213 +++++++++++++++ sql/migrate_order_operation_log.sql | 42 +++ .../Dtos/Orders/OrderOperationLogDtos.cs | 44 ++++ .../Orders/OrderOperationLogSnapshot.cs | 247 ++++++++++++++++++ .../Orders/OrderOperationLogWriter.cs | 132 ++++++++++ .../Orders/OrderService.OperationLog.cs | 55 ++++ .../Services/Orders/OrderService.OrderEdit.cs | 69 ++++- .../Consts/OrderOperationActionCodes.cs | 20 ++ .../Entities/OrderOperationLog.cs | 36 +++ .../IOrderOperationLogRepository.cs | 8 + .../Orders/OrderOperationLogRepository.cs | 13 + 12 files changed, 896 insertions(+), 7 deletions(-) create mode 100644 sql/drop_all_unique_indexes.sql create mode 100644 sql/migrate_order_operation_log.sql create mode 100644 src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs create mode 100644 src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs create mode 100644 src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.OperationLog.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs create mode 100644 src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs diff --git a/db.sql b/db.sql index f55db57..485e33b 100644 --- a/db.sql +++ b/db.sql @@ -104,6 +104,7 @@ DROP TABLE IF EXISTS `t_order_sender`; DROP TABLE IF EXISTS `t_order_batch_job_item`; DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_template`; +DROP TABLE IF EXISTS `t_order_operation_log`; DROP TABLE IF EXISTS `t_order_exception_event`; DROP TABLE IF EXISTS `t_order`; @@ -528,6 +529,29 @@ CREATE TABLE `t_order_exception_event` ( KEY `idx_oee_order_time` (`OrderId`, `CreateTime`) ) COMMENT='订单异常事件表'; +CREATE TABLE `t_order_operation_log` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OrderId` INT NOT NULL COMMENT '运单Id', + `ServiceNo` VARCHAR(64) NULL COMMENT '运单号冗余', + `Action` VARCHAR(32) NOT NULL COMMENT '动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate', + `ActionName` VARCHAR(64) NOT NULL COMMENT '动作中文名', + `OperatorId` INT NOT NULL DEFAULT 0 COMMENT '操作人用户Id', + `OperatorName` VARCHAR(64) NULL COMMENT '操作人展示名', + `OperatorType` VARCHAR(16) NOT NULL DEFAULT 'staff' COMMENT 'staff/customer/system/openapi', + `Source` VARCHAR(16) NOT NULL DEFAULT 'pc' COMMENT 'pc/h5/openapi/job', + `ChangesJson` JSON NULL COMMENT '字段 diff 数组 [{path,label,before,after}]', + `Remark` VARCHAR(512) NULL COMMENT '补充说明', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_ool_order_time` (`OrderId`, `CreateTime`), + KEY `idx_ool_service_no` (`ServiceNo`) +) COMMENT='运单操作日志'; + CREATE TABLE `t_sales_product` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `ProductCode` VARCHAR(64) NOT NULL COMMENT '产品编码', diff --git a/sql/drop_all_unique_indexes.sql b/sql/drop_all_unique_indexes.sql new file mode 100644 index 0000000..4f8d7cc --- /dev/null +++ b/sql/drop_all_unique_indexes.sql @@ -0,0 +1,213 @@ +-- ============================================================================= +-- drop_all_unique_indexes.sql +-- 去掉当前库所有非 PRIMARY 唯一索引的「唯一性」(改为同名普通 INDEX) +-- 流程:备份并删除外键 → UNIQUE 改 KEY → 恢复外键 +-- 解决 Error 1553:Cannot drop index ... needed in a foreign key constraint +-- 可重复执行;唯一性请在业务代码中校验。 +-- 请在 Navicat / DBeaver / MySQL Workbench 中整段执行(需要 DELIMITER)。 +-- ============================================================================= + +SET @db := DATABASE(); + +-- ---------- 预览:将处理的唯一索引 ---------- +SELECT + TABLE_NAME, + INDEX_NAME, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @db + AND NON_UNIQUE = 0 + AND INDEX_NAME <> 'PRIMARY' +GROUP BY TABLE_NAME, INDEX_NAME +ORDER BY TABLE_NAME, INDEX_NAME; + +-- ---------- 预览:库中物理外键(会临时删除再重建)---------- +SELECT + rc.CONSTRAINT_NAME, + rc.TABLE_NAME, + rc.REFERENCED_TABLE_NAME, + rc.UPDATE_RULE, + rc.DELETE_RULE +FROM information_schema.REFERENTIAL_CONSTRAINTS rc +WHERE rc.CONSTRAINT_SCHEMA = @db +ORDER BY rc.TABLE_NAME, rc.CONSTRAINT_NAME; + +DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`; +DELIMITER $$ +CREATE PROCEDURE `sp_drop_all_unique_indexes`() +BEGIN + DECLARE done INT DEFAULT 0; + DECLARE v_fk_name VARCHAR(128); + DECLARE v_fk_table VARCHAR(128); + DECLARE v_ref_table VARCHAR(128); + DECLARE v_update VARCHAR(16); + DECLARE v_delete VARCHAR(16); + DECLARE v_cols TEXT; + DECLARE v_ref_cols TEXT; + DECLARE v_table VARCHAR(128); + DECLARE v_index VARCHAR(128); + DECLARE v_index_cols TEXT; + DECLARE v_sql TEXT; + + -- 1) 备份当前库全部外键定义 + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`; + CREATE TEMPORARY TABLE `_tmp_fk_backup` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `constraint_name` VARCHAR(128) NOT NULL, + `table_name` VARCHAR(128) NOT NULL, + `column_names` TEXT NOT NULL, + `ref_table_name` VARCHAR(128) NOT NULL, + `ref_column_names` TEXT NOT NULL, + `update_rule` VARCHAR(16) NOT NULL, + `delete_rule` VARCHAR(16) NOT NULL + ); + + INSERT INTO `_tmp_fk_backup` ( + `constraint_name`, `table_name`, `column_names`, + `ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule` + ) + SELECT + kcu.CONSTRAINT_NAME, + kcu.TABLE_NAME, + GROUP_CONCAT(CONCAT('`', kcu.COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','), + kcu.REFERENCED_TABLE_NAME, + GROUP_CONCAT(CONCAT('`', kcu.REFERENCED_COLUMN_NAME, '`') ORDER BY kcu.ORDINAL_POSITION SEPARATOR ','), + rc.UPDATE_RULE, + rc.DELETE_RULE + FROM information_schema.KEY_COLUMN_USAGE kcu + INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS rc + ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND rc.TABLE_NAME = kcu.TABLE_NAME + WHERE kcu.TABLE_SCHEMA = DATABASE() + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + GROUP BY + kcu.CONSTRAINT_NAME, + kcu.TABLE_NAME, + kcu.REFERENCED_TABLE_NAME, + rc.UPDATE_RULE, + rc.DELETE_RULE; + + -- 2) 删除全部外键(解除对唯一索引的占用) + BEGIN + DECLARE cur_fk_drop CURSOR FOR + SELECT `constraint_name`, `table_name` FROM `_tmp_fk_backup` ORDER BY `id`; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + SET done = 0; + OPEN cur_fk_drop; + fk_drop_loop: LOOP + FETCH cur_fk_drop INTO v_fk_name, v_fk_table; + IF done = 1 THEN + LEAVE fk_drop_loop; + END IF; + SET v_sql = CONCAT('ALTER TABLE `', v_fk_table, '` DROP FOREIGN KEY `', v_fk_name, '`'); + SET @ddl := v_sql; + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END LOOP; + CLOSE cur_fk_drop; + END; + + -- 3) 唯一索引 → 同名普通 INDEX(去掉唯一性,保留列与查询能力,便于外键重建) + BEGIN + DECLARE cur_uk CURSOR FOR + SELECT + s.TABLE_NAME, + s.INDEX_NAME, + GROUP_CONCAT(CONCAT('`', s.COLUMN_NAME, '`') ORDER BY s.SEQ_IN_INDEX SEPARATOR ',') + FROM information_schema.STATISTICS s + WHERE s.TABLE_SCHEMA = DATABASE() + AND s.NON_UNIQUE = 0 + AND s.INDEX_NAME <> 'PRIMARY' + GROUP BY s.TABLE_NAME, s.INDEX_NAME + ORDER BY s.TABLE_NAME, s.INDEX_NAME; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + SET done = 0; + OPEN cur_uk; + uk_loop: LOOP + FETCH cur_uk INTO v_table, v_index, v_index_cols; + IF done = 1 THEN + LEAVE uk_loop; + END IF; + SET v_sql = CONCAT( + 'ALTER TABLE `', v_table, '` DROP INDEX `', v_index, + '`, ADD INDEX `', v_index, '` (', v_index_cols, ')' + ); + SET @ddl := v_sql; + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END LOOP; + CLOSE cur_uk; + END; + + -- 4) 按备份重建外键 + -- 若原外键引用的是「非主键唯一键」,去掉唯一性后 InnoDB 可能拒绝重建,将跳过并写入警告表 + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`; + CREATE TEMPORARY TABLE `_tmp_fk_restore_fail` ( + `constraint_name` VARCHAR(128) NOT NULL, + `table_name` VARCHAR(128) NOT NULL, + `error_msg` TEXT + ); + + BEGIN + DECLARE cur_fk_add CURSOR FOR + SELECT + `constraint_name`, `table_name`, `column_names`, + `ref_table_name`, `ref_column_names`, `update_rule`, `delete_rule` + FROM `_tmp_fk_backup` + ORDER BY `id`; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + DECLARE CONTINUE HANDLER FOR SQLEXCEPTION + BEGIN + GET DIAGNOSTICS CONDITION 1 @fk_err = MESSAGE_TEXT; + INSERT INTO `_tmp_fk_restore_fail` (`constraint_name`, `table_name`, `error_msg`) + VALUES (v_fk_name, v_fk_table, @fk_err); + END; + SET done = 0; + OPEN cur_fk_add; + fk_add_loop: LOOP + FETCH cur_fk_add INTO + v_fk_name, v_fk_table, v_cols, v_ref_table, v_ref_cols, v_update, v_delete; + IF done = 1 THEN + LEAVE fk_add_loop; + END IF; + SET v_sql = CONCAT( + 'ALTER TABLE `', v_fk_table, + '` ADD CONSTRAINT `', v_fk_name, + '` FOREIGN KEY (', v_cols, + ') REFERENCES `', v_ref_table, '` (', v_ref_cols, + ') ON UPDATE ', v_update, + ' ON DELETE ', v_delete + ); + SET @ddl := v_sql; + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END LOOP; + CLOSE cur_fk_add; + END; + + -- 输出重建失败的外键(通常因引用侧不再是 UNIQUE/PRIMARY) + SELECT * FROM `_tmp_fk_restore_fail`; + + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_backup`; + DROP TEMPORARY TABLE IF EXISTS `_tmp_fk_restore_fail`; +END$$ +DELIMITER ; + +CALL `sp_drop_all_unique_indexes`(); +DROP PROCEDURE IF EXISTS `sp_drop_all_unique_indexes`; + +-- ---------- 校验:唯一索引应无结果(PRIMARY 除外)---------- +SELECT + TABLE_NAME, + INDEX_NAME, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = @db + AND NON_UNIQUE = 0 + AND INDEX_NAME <> 'PRIMARY' +GROUP BY TABLE_NAME, INDEX_NAME +ORDER BY TABLE_NAME, INDEX_NAME; diff --git a/sql/migrate_order_operation_log.sql b/sql/migrate_order_operation_log.sql new file mode 100644 index 0000000..cd0aba0 --- /dev/null +++ b/sql/migrate_order_operation_log.sql @@ -0,0 +1,42 @@ +-- ============================================================================= +-- migrate_order_operation_log.sql +-- 运单操作日志表(业务动作 + 字段级 before/after diff) +-- 可重复执行 +-- ============================================================================= +SET @db := DATABASE(); + +SET @tbl := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_operation_log' +); +SET @sql := IF( + @tbl = 0, + 'CREATE TABLE `t_order_operation_log` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `OrderId` INT NOT NULL COMMENT ''运单Id'', + `ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'', + `Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'', + `ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'', + `OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'', + `OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'', + `OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'', + `Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'', + `ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'', + `Remark` VARCHAR(512) NULL COMMENT ''补充说明'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + KEY `idx_ool_order_time` (`OrderId`, `CreateTime`), + KEY `idx_ool_service_no` (`ServiceNo`) + ) COMMENT=''运单操作日志''', + 'SELECT ''t_order_operation_log already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs new file mode 100644 index 0000000..3b787e0 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs @@ -0,0 +1,44 @@ +namespace Oms.Application.Dtos.Orders; + +public class OrderOperationLogChangeItem +{ + public string Path { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + + public string? Before { get; set; } + + public string? After { get; set; } +} + +public class OrderOperationLogOutput +{ + public int Id { get; set; } + + public int OrderId { get; set; } + + public string? ServiceNo { get; set; } + + public string Action { get; set; } = string.Empty; + + public string ActionName { get; set; } = string.Empty; + + public int OperatorId { get; set; } + + public string? OperatorName { get; set; } + + public string OperatorType { get; set; } = string.Empty; + + public string Source { get; set; } = string.Empty; + + public List Changes { get; set; } = []; + + public string? Remark { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class OrderOperationLogListOutput +{ + public List Items { get; set; } = []; +} diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs new file mode 100644 index 0000000..8a3d31c --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs @@ -0,0 +1,247 @@ +using System.Globalization; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +/// +/// 运单操作日志白名单快照与字段 label。 +/// +public static class OrderOperationLogSnapshot +{ + private static readonly Dictionary Labels = new(StringComparer.OrdinalIgnoreCase) + { + ["orderStatus"] = "订单状态", + ["clientNo"] = "客户单号", + ["trackNo"] = "跟踪号", + ["channelCode"] = "服务渠道", + ["productCode"] = "销售产品", + ["warehouseId"] = "仓库", + ["cargoAttribute"] = "货物属性", + ["isCod"] = "是否COD", + ["codCurrency"] = "COD币种", + ["codSum"] = "COD金额", + ["declaredCurrency"] = "申报币种", + ["declaredSum"] = "申报金额", + ["quantity"] = "包裹数量", + ["receivableSubtotal"] = "应收小计", + ["payableAmount"] = "应付金额", + ["downstreamServiceNo"] = "服务商单号", + ["allocationScheme"] = "排货方案", + ["isPaid"] = "是否已支付", + ["sender.name"] = "发件人姓名", + ["sender.phone"] = "发件人电话", + ["sender.country"] = "发件国家", + ["sender.province"] = "发件省/州", + ["sender.city"] = "发件城市", + ["sender.zipCode"] = "发件邮编", + ["sender.address1"] = "发件地址1", + ["sender.address2"] = "发件地址2", + ["receiver.name"] = "收件人姓名", + ["receiver.phone"] = "收件人电话", + ["receiver.country"] = "收件国家", + ["receiver.province"] = "收件省/州", + ["receiver.city"] = "收件城市", + ["receiver.zipCode"] = "收件邮编", + ["receiver.address1"] = "收件地址1", + ["receiver.address2"] = "收件地址2", + ["fee.receivableTotal"] = "应收合计", + ["fee.payableTotal"] = "应付合计", + }; + + public static Dictionary CaptureOrder(Order order) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["orderStatus"] = Norm(order.OrderStatus), + ["clientNo"] = Norm(order.ClientNo), + ["trackNo"] = Norm(order.TrackNo), + ["channelCode"] = Norm(order.ChannelCode), + ["productCode"] = Norm(order.ProductCode), + ["warehouseId"] = FormatInt(order.WarehouseId), + ["cargoAttribute"] = Norm(order.CargoAttribute), + ["isCod"] = FormatByte(order.IsCod), + ["codCurrency"] = Norm(order.CodCurrency), + ["codSum"] = FormatDecimal(order.CodSum), + ["declaredCurrency"] = Norm(order.DeclaredCurrency), + ["declaredSum"] = FormatDecimal(order.DeclaredSum), + ["quantity"] = FormatInt(order.Quantity), + ["receivableSubtotal"] = FormatDecimal(order.ReceivableSubtotal), + ["payableAmount"] = FormatDecimal(order.PayableAmount), + ["downstreamServiceNo"] = Norm(order.DownstreamServiceNo), + ["allocationScheme"] = Norm(order.AllocationScheme), + ["isPaid"] = FormatByte(order.IsPaid), + }; + } + + public static Dictionary CaptureSender(OrderSender? sender) + { + if (sender == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sender.name"] = Norm(sender.SenderName), + ["sender.phone"] = Norm(sender.Phone), + ["sender.country"] = FormatInt(sender.Country), + ["sender.province"] = Norm(sender.Province), + ["sender.city"] = Norm(sender.City), + ["sender.zipCode"] = Norm(sender.ZipCode), + ["sender.address1"] = Norm(sender.StreetAddress1), + ["sender.address2"] = Norm(sender.StreetAddress2), + }; + } + + public static Dictionary CaptureReceiver(OrderRecipient? recipient) + { + if (recipient == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["receiver.name"] = Norm(recipient.RecipientsName), + ["receiver.phone"] = Norm(recipient.Phone), + ["receiver.country"] = FormatInt(recipient.Country), + ["receiver.province"] = Norm(recipient.Province), + ["receiver.city"] = Norm(recipient.City), + ["receiver.zipCode"] = Norm(recipient.ZipCode), + ["receiver.address1"] = Norm(recipient.StreetAddress1), + ["receiver.address2"] = Norm(recipient.StreetAddress2), + }; + } + + public static Dictionary CaptureWeights(IEnumerable weights) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in weights) + { + var type = string.IsNullOrWhiteSpace(row.Type) ? "unknown" : row.Type.Trim().ToLowerInvariant(); + var prefix = $"weight.{type}"; + map[$"{prefix}.actualWeight"] = FormatDecimal(row.ActualWeight); + map[$"{prefix}.length"] = FormatDecimal(row.Length); + map[$"{prefix}.width"] = FormatDecimal(row.Width); + map[$"{prefix}.height"] = FormatDecimal(row.Height); + map[$"{prefix}.chargeableWeight"] = FormatDecimal(row.ChargeableWeight); + } + + return map; + } + + public static Dictionary CaptureFeeTotals(IEnumerable fees) + { + var list = fees.ToList(); + var receivable = list + .Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Receivable, StringComparison.OrdinalIgnoreCase)) + .Sum(x => x.Amount); + var payable = list + .Where(x => string.Equals(x.Type, OrderFeeTypeCodes.Payable, StringComparison.OrdinalIgnoreCase)) + .Sum(x => x.Amount); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["fee.receivableTotal"] = FormatDecimal(receivable), + ["fee.payableTotal"] = FormatDecimal(payable), + }; + } + + public static Dictionary Merge(params IReadOnlyDictionary[] parts) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in parts) + { + foreach (var (key, value) in part) + { + map[key] = value; + } + } + + return map; + } + + public static List<(string Path, string Label, string? Before, string? After)> Diff( + IReadOnlyDictionary before, + IReadOnlyDictionary after) + { + var keys = before.Keys.Union(after.Keys, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(); + var changes = new List<(string, string, string?, string?)>(); + foreach (var key in keys) + { + before.TryGetValue(key, out var b); + after.TryGetValue(key, out var a); + b = Norm(b); + a = Norm(a); + if (string.Equals(b, a, StringComparison.Ordinal)) + { + continue; + } + + changes.Add((key, ResolveLabel(key), b, a)); + } + + return changes; + } + + public static string ResolveLabel(string path) + { + if (Labels.TryGetValue(path, out var label)) + { + return label; + } + + if (path.StartsWith("weight.", StringComparison.OrdinalIgnoreCase)) + { + var parts = path.Split('.'); + if (parts.Length >= 3) + { + var typeName = parts[1] switch + { + "forecast" => "预报", + "receive" => "收货", + "ship" => "发货", + "downstream" => "对账", + _ => parts[1] + }; + var fieldName = parts[2] switch + { + "actualWeight" => "实重", + "length" => "长", + "width" => "宽", + "height" => "高", + "chargeableWeight" => "计费重", + _ => parts[2] + }; + return $"{typeName}{fieldName}"; + } + } + + return path; + } + + private static string? Norm(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim(); + } + + private static string? FormatDecimal(decimal? value) + { + if (!value.HasValue) + { + return null; + } + + return value.Value.ToString("0.####", CultureInfo.InvariantCulture); + } + + private static string? FormatInt(int? value) => value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : null; + + private static string FormatByte(byte value) => value == 0 ? "0" : "1"; +} diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs new file mode 100644 index 0000000..d5b3aea --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs @@ -0,0 +1,132 @@ +using Fake.DependencyInjection; +using Fake.Security.Claims; +using Fake.Users; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +/// +/// 运单操作日志写入器:字段 diff 落库;失败不影响主业务。 +/// +public class OrderOperationLogWriter : IScopedDependency +{ + private readonly IOrderOperationLogRepository _repository; + private readonly ICurrentUser _currentUser; + private readonly OmsRbacUserDisplayNameResolver _userDisplayNameResolver; + private readonly ILogger _logger; + + public OrderOperationLogWriter( + IOrderOperationLogRepository repository, + ICurrentUser currentUser, + OmsRbacUserDisplayNameResolver userDisplayNameResolver, + ILogger logger) + { + _repository = repository; + _currentUser = currentUser; + _userDisplayNameResolver = userDisplayNameResolver; + _logger = logger; + } + + public async Task TryWriteAsync( + Order order, + string action, + IReadOnlyDictionary before, + IReadOnlyDictionary after, + string? remark = null) + { + try + { + var changes = OrderOperationLogSnapshot.Diff(before, after); + var operatorId = 0; + string? operatorName = null; + if (_currentUser.IsAuthenticated) + { + operatorId = _currentUser.GetId(); + operatorName = await _userDisplayNameResolver.ResolveAsync(operatorId); + } + + var entity = new OrderOperationLog + { + OrderId = order.Id, + ServiceNo = order.ServiceNo, + Action = action, + ActionName = OrderOperationActionCodes.GetActionName(action), + OperatorId = operatorId, + OperatorName = operatorName, + OperatorType = "staff", + Source = "pc", + ChangesJson = JsonConvert.SerializeObject( + changes.Select(x => new OrderOperationLogChangeItem + { + Path = x.Path, + Label = x.Label, + Before = x.Before, + After = x.After + }).ToList()), + Remark = string.IsNullOrWhiteSpace(remark) ? null : remark.Trim(), + CreateUserId = operatorId, + CreateTime = DateTime.Now + }; + + await _repository.InsertAsync(entity); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "写入运单操作日志失败 OrderId={OrderId} Action={Action}", + order.Id, + action); + } + } + + public async Task ListByOrderIdAsync(int orderId) + { + var rows = await _repository.GetListAsync(x => x.OrderId == orderId); + var items = rows + .OrderByDescending(x => x.CreateTime) + .ThenByDescending(x => x.Id) + .Select(MapOutput) + .ToList(); + return new OrderOperationLogListOutput { Items = items }; + } + + private static OrderOperationLogOutput MapOutput(OrderOperationLog row) + { + var changes = new List(); + if (!string.IsNullOrWhiteSpace(row.ChangesJson)) + { + try + { + changes = JsonConvert.DeserializeObject>(row.ChangesJson) + ?? []; + } + catch + { + changes = []; + } + } + + return new OrderOperationLogOutput + { + Id = row.Id, + OrderId = row.OrderId, + ServiceNo = row.ServiceNo, + Action = row.Action, + ActionName = row.ActionName, + OperatorId = row.OperatorId, + OperatorName = row.OperatorName, + OperatorType = row.OperatorType, + Source = row.Source, + Changes = changes, + Remark = row.Remark, + CreateTime = row.CreateTime + }; + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs new file mode 100644 index 0000000..eefa146 --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.OperationLog.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private OrderOperationLogWriter OperationLogWriter => + LazyServiceProvider.GetRequiredService(); + + /// + /// 运单操作日志列表(按时间倒序)。 + /// + [HttpGet("/order/operation-log/list")] + public async Task GetOperationLogListAsync([FromQuery] int orderId) + { + await GetOrderOrThrowAsync(orderId); + return await OperationLogWriter.ListByOrderIdAsync(orderId); + } + + private async Task> CaptureOrderAuditSnapshotAsync( + Order order, + bool includeParties = false, + bool includeWeights = false, + bool includeFees = false) + { + var parts = new List> + { + OrderOperationLogSnapshot.CaptureOrder(order) + }; + + if (includeParties) + { + var senders = await OrderRepository.GetSendersByOrderIdsAsync([order.Id]); + var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); + parts.Add(OrderOperationLogSnapshot.CaptureSender(senders.FirstOrDefault())); + parts.Add(OrderOperationLogSnapshot.CaptureReceiver(recipients.FirstOrDefault())); + } + + if (includeWeights) + { + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + parts.Add(OrderOperationLogSnapshot.CaptureWeights(weights)); + } + + if (includeFees) + { + var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); + parts.Add(OrderOperationLogSnapshot.CaptureFeeTotals(fees)); + } + + return OrderOperationLogSnapshot.Merge(parts.ToArray()); + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 616c9f0..b0e9653 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -28,6 +28,7 @@ public partial class OrderService public async Task UpdateOrderDetailAsync(OrderUpdateInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true); if (input.ClientNo != null) { @@ -134,6 +135,16 @@ public partial class OrderService input.Items.Select(x => MapDeclarationInput(order.Id, x))); } + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true); + var remark = input.Items != null ? "申报信息已更新" : null; + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.EditBasic, + beforeSnap, + afterSnap, + remark); + return (await MapOrderOutputsAsync([order])).First(); } @@ -149,6 +160,7 @@ public partial class OrderService } var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); if (string.IsNullOrWhiteSpace(order.ProductCode)) { throw new BusinessException("订单未绑定销售产品,无法重算计费重"); @@ -224,6 +236,14 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.EditWeight, + beforeSnap, + afterSnap); + return (await MapOrderOutputsAsync([order])).First(); } @@ -234,8 +254,16 @@ public partial class OrderService public async Task RecalculateReceivableFeesAsync(OrderActionInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); EnsureReceivableMutable(order); await RecalculateReceivableCoreAsync(order); + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.RecalcReceivable, + beforeSnap, + afterSnap); return (await MapOrderOutputsAsync([order])).First(); } @@ -246,7 +274,15 @@ public partial class OrderService public async Task RecalculatePayableFeesAsync(OrderActionInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); await RecalculatePayableCoreAsync(order); + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.RecalcPayable, + beforeSnap, + afterSnap); return (await MapOrderOutputsAsync([order])).First(); } @@ -566,8 +602,17 @@ public partial class OrderService public async Task ReallocateAsync([FromBody] OrderReallocateInput input) { var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId); order = await GetOrderOrThrowAsync(input.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + var remark = string.IsNullOrWhiteSpace(input.Scheme) ? null : $"scheme={input.Scheme.Trim()}"; + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.Reallocate, + beforeSnap, + afterSnap, + remark); return (await MapOrderOutputsAsync([order])).First(); } @@ -580,12 +625,22 @@ public partial class OrderService NormalizeReallocateOptions(input.Scheme, input.ServiceChannelId, out var scheme, out var channelId); var result = new OrderBatchActionResultOutput(); var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList(); + var remark = string.IsNullOrWhiteSpace(scheme) ? null : $"scheme={scheme}"; foreach (var orderId in orderIds) { try { var order = await GetOrderOrThrowAsync(orderId); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); await ReallocateCoreAsync(order, scheme, channelId); + order = await GetOrderOrThrowAsync(orderId); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.Reallocate, + beforeSnap, + afterSnap, + remark); result.SuccessCount++; } catch (Exception ex) @@ -990,15 +1045,15 @@ public partial class OrderService private static void EnsureReallocateAllowed(Order order) { - if (order.IsPaid != 0) - { - throw new BusinessException("订单已付款,不允许重新排货"); - } - - if (order.OrderStatus is not (OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment)) + // 已付款也可排货:只重算应付与服务渠道,不改应收。 + if (order.OrderStatus is not ( + OrderStatusCodes.Predeclared + or OrderStatusCodes.PendingAllocation + or OrderStatusCodes.PendingPayment + or OrderStatusCodes.PendingShip)) { throw new BusinessException( - $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅待排货/待支付可重新排货"); + $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅已预报/待排货/待支付/待发货可重新排货"); } } diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs new file mode 100644 index 0000000..866ac0d --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -0,0 +1,20 @@ +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 static string GetActionName(string action) => action switch + { + EditBasic => "编辑订单详情", + EditWeight => "修改件重尺", + RecalcReceivable => "重算应收", + RecalcPayable => "重算应付", + Reallocate => "重新排货", + _ => action + }; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs new file mode 100644 index 0000000..703354b --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderOperationLog.cs @@ -0,0 +1,36 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +[SugarTable("t_order_operation_log")] +public class OrderOperationLog : OmsAuditedAggregateRoot +{ + public int OrderId { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? ServiceNo { get; set; } + + [SugarColumn(Length = 32)] + public string Action { get; set; } = string.Empty; + + [SugarColumn(Length = 64)] + public string ActionName { get; set; } = string.Empty; + + public int OperatorId { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? OperatorName { get; set; } + + [SugarColumn(Length = 16)] + public string OperatorType { get; set; } = "staff"; + + [SugarColumn(Length = 16)] + public string Source { get; set; } = "pc"; + + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? ChangesJson { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs new file mode 100644 index 0000000..b1321bf --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderOperationLogRepository.cs @@ -0,0 +1,8 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface IOrderOperationLogRepository : IRepository +{ +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs new file mode 100644 index 0000000..c020ea9 --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/OrderOperationLogRepository.cs @@ -0,0 +1,13 @@ +using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Orders; + +public class OrderOperationLogRepository + : OmsSqlSugarRepository, + IOrderOperationLogRepository, + IScopedDependency +{ +}