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 1/4] =?UTF-8?q?feat:=20=E8=BF=90=E5=8D=95=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E5=BF=97=20MVP=EF=BC=8C=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=BC=96=E8=BE=91/=E4=BB=B6=E9=87=8D=E5=B0=BA/=E9=87=8D?= =?UTF-8?q?=E7=AE=97/=E6=8E=92=E8=B4=A7=E5=AD=97=E6=AE=B5=E5=8F=98?= =?UTF-8?q?=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 +{ +} From da27fa4377790835d1e191038e9e240b2177aa0a Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 9 Oct 2026 18:16:38 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E9=87=8D=E7=AE=97=E5=BA=94?= =?UTF-8?q?=E4=BB=98=E6=94=BE=E8=A1=8C=E9=94=81=E6=B8=A0=E9=81=93=E6=B1=A0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E6=94=AF=E6=8C=81=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC/=E8=A7=84=E5=88=99=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E4=B8=8E=E6=9C=8D=E5=8A=A1=E5=95=86=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- db.sql | 1 + sql/migrate_20261009_order_warehouse.sql | 105 ++++++++++++++ ...migrate_order_downstream_supplier_name.sql | 22 +++ .../Orders/OrderBindSnapshotVersionsDtos.cs | 34 +++++ .../Dtos/Orders/OrderOutput.cs | 15 ++ .../Orders/OrderOperationLogSnapshot.cs | 4 + .../Services/Orders/OrderService.Helpers.cs | 8 ++ .../Services/Orders/OrderService.OrderEdit.cs | 131 +++++++++++++++++- .../Orders/OrderService.ParcelBilling.cs | 87 +++++++++--- .../AllocationProviderSnapshotEnricher.cs | 3 + .../Consts/OrderOperationActionCodes.cs | 2 + .../OrdersAggregate/Entities/Order.cs | 6 + 12 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 sql/migrate_20261009_order_warehouse.sql create mode 100644 sql/migrate_order_downstream_supplier_name.sql create mode 100644 src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs diff --git a/db.sql b/db.sql index 485e33b..3f1111e 100644 --- a/db.sql +++ b/db.sql @@ -118,6 +118,7 @@ CREATE TABLE `t_order` ( `ProductCode` VARCHAR(128) NULL COMMENT '产品编码', `ChannelCode` VARCHAR(128) NULL COMMENT '渠道编码', `DownstreamSupplierCode` VARCHAR(64) NULL COMMENT '下游 API 服务商编码 supplierCode', + `DownstreamSupplierName` VARCHAR(128) NULL COMMENT '下游 API 服务商名称', `DownstreamAppKey` VARCHAR(128) NULL COMMENT '下游 itfx 应用 AppKey', `DownstreamAppId` INT NULL COMMENT '下游 itfx 应用 Id', `ChannelForecastCode` VARCHAR(128) NULL COMMENT '渠道预报编码 ForecastCode', diff --git a/sql/migrate_20261009_order_warehouse.sql b/sql/migrate_20261009_order_warehouse.sql new file mode 100644 index 0000000..c8c2b84 --- /dev/null +++ b/sql/migrate_20261009_order_warehouse.sql @@ -0,0 +1,105 @@ +-- ============================================================================= +-- migrate_20261009_order_warehouse.sql +-- 汇总: +-- 1) t_order.DownstreamSupplierName(下游服务商名称) +-- 2) t_order_operation_log(运单操作日志) +-- 3) t_warehouse_address.Sort / Remark(仓库地址排序与备注) +-- 可重复执行。 +-- ============================================================================= + +SET @db := DATABASE(); + +-- ----------------------------------------------------------------------------- +-- 1) 订单:下游服务商名称 +-- ----------------------------------------------------------------------------- +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'DownstreamSupplierName' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`', + 'SELECT ''t_order.DownstreamSupplierName already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 2) 运单操作日志表 +-- ----------------------------------------------------------------------------- +SET @tbl := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_operation_log' +); +SET @sql := IF( + @tbl = 0, + 'CREATE TABLE `t_order_operation_log` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `OrderId` INT NOT NULL COMMENT ''运单Id'', + `ServiceNo` VARCHAR(64) NULL COMMENT ''运单号冗余'', + `Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate'', + `ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'', + `OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'', + `OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'', + `OperatorType` VARCHAR(16) NOT NULL DEFAULT ''staff'' COMMENT ''staff/customer/system/openapi'', + `Source` VARCHAR(16) NOT NULL DEFAULT ''pc'' COMMENT ''pc/h5/openapi/job'', + `ChangesJson` JSON NULL COMMENT ''字段 diff 数组 [{path,label,before,after}]'', + `Remark` VARCHAR(512) NULL COMMENT ''补充说明'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + KEY `idx_ool_order_time` (`OrderId`, `CreateTime`), + KEY `idx_ool_service_no` (`ServiceNo`) + ) COMMENT=''运单操作日志''', + 'SELECT ''t_order_operation_log already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 3) 仓库地址:展示排序 + 备注 +-- ----------------------------------------------------------------------------- +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Sort' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`', + 'SELECT ''t_warehouse_address.Sort already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`', + 'SELECT ''t_warehouse_address.Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_order_downstream_supplier_name.sql b/sql/migrate_order_downstream_supplier_name.sql new file mode 100644 index 0000000..f3ba5ad --- /dev/null +++ b/sql/migrate_order_downstream_supplier_name.sql @@ -0,0 +1,22 @@ +-- 订单增加下游服务商名称(排货确认时写入 itfx 服务商名 / 分组名)。 +-- 可重复执行。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order' + AND COLUMN_NAME = 'DownstreamSupplierName' +); + +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `DownstreamSupplierName` VARCHAR(128) NULL COMMENT ''下游 API 服务商名称'' AFTER `DownstreamSupplierCode`', + 'SELECT ''t_order.DownstreamSupplierName already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs new file mode 100644 index 0000000..2abfce4 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderBindSnapshotVersionsDtos.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 切换订单绑定的销售产品规则/价格发布版本。 +/// +public class OrderBindSnapshotVersionsInput +{ + [Required] + public int Id { get; set; } + + /// + /// 目标规则版本;空表示不改。特殊值 CURRENT 表示绑定当前有效规则发布。 + /// + [StringLength(64)] + public string? RulesVersion { get; set; } + + /// + /// 目标价格版本;空表示不改。特殊值 CURRENT 表示绑定当前有效价格发布。 + /// + [StringLength(64)] + public string? PriceVersion { get; set; } + + /// + /// 绑定后是否立即重算应收(已支付会拒绝)。 + /// + public bool RecalculateReceivable { get; set; } + + /// + /// 绑定后是否立即重算应付(锁定当前渠道)。 + /// + public bool RecalculatePayable { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index a82aea9..4eaed1f 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -55,6 +55,11 @@ public class OrderOutput /// public string? DownstreamSupplierCode { get; set; } + /// + /// 下游 API 服务商名称。 + /// + public string? DownstreamSupplierName { get; set; } + /// /// 下游 itfx 应用 AppKey。 /// @@ -363,6 +368,16 @@ public class OrderOutput /// public decimal? ForecastPayableAmount { get; set; } + /// + /// 订单绑定的销售产品规则发布版本。 + /// + public string? SalesProductRulesVersion { get; set; } + + /// + /// 订单绑定的销售产品价格发布版本。 + /// + public string? SalesProductPriceVersion { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs index 8a3d31c..527471a 100644 --- a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs +++ b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs @@ -28,6 +28,8 @@ public static class OrderOperationLogSnapshot ["payableAmount"] = "应付金额", ["downstreamServiceNo"] = "服务商单号", ["allocationScheme"] = "排货方案", + ["salesProductRulesVersion"] = "规则版本", + ["salesProductPriceVersion"] = "价格版本", ["isPaid"] = "是否已支付", ["sender.name"] = "发件人姓名", ["sender.phone"] = "发件人电话", @@ -70,6 +72,8 @@ public static class OrderOperationLogSnapshot ["payableAmount"] = FormatDecimal(order.PayableAmount), ["downstreamServiceNo"] = Norm(order.DownstreamServiceNo), ["allocationScheme"] = Norm(order.AllocationScheme), + ["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion), + ["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion), ["isPaid"] = FormatByte(order.IsPaid), }; } diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index c5d6e34..40f81fa 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -165,6 +165,7 @@ public partial class OrderService MaxTimeliness = product?.MaxTimeliness, ChannelCode = order.ChannelCode, DownstreamSupplierCode = order.DownstreamSupplierCode, + DownstreamSupplierName = order.DownstreamSupplierName, DownstreamAppKey = order.DownstreamAppKey, DownstreamAppId = order.DownstreamAppId, ChannelForecastCode = order.ChannelForecastCode, @@ -262,6 +263,12 @@ public partial class OrderService HasInsurance = hasInsurance, ForecastReceivableAmount = order.ForecastReceivableAmount, ForecastPayableAmount = order.ForecastPayableAmount, + SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion) + ? null + : order.SalesProductRulesVersion.Trim(), + SalesProductPriceVersion = string.IsNullOrWhiteSpace(order.SalesProductPriceVersion) + ? null + : order.SalesProductPriceVersion.Trim(), CreateTime = order.CreateTime, CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap), UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime), @@ -387,6 +394,7 @@ public partial class OrderService output.ChannelCode = null; output.ChannelName = null; output.DownstreamSupplierCode = null; + output.DownstreamSupplierName = null; output.DownstreamAppKey = null; output.DownstreamAppId = null; output.ChannelForecastCode = null; diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index b0e9653..9049e59 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -286,6 +286,127 @@ public partial class OrderService return (await MapOrderOutputsAsync([order])).First(); } + /// + /// 切换订单绑定的销售产品规则/价格发布版本(显式改契约后再重算)。 + /// + [HttpPost("/order/fee/bind-snapshot-versions")] + public async Task BindSnapshotVersionsAsync([FromBody] OrderBindSnapshotVersionsInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + throw new BusinessException("订单未绑定销售产品,无法切换版本"); + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null) + { + throw new BusinessException("销售产品不存在"); + } + + var wantRules = !string.IsNullOrWhiteSpace(input.RulesVersion); + var wantPrice = !string.IsNullOrWhiteSpace(input.PriceVersion); + if (!wantRules && !wantPrice) + { + throw new BusinessException("请至少指定规则版本或价格版本"); + } + + var remarkParts = new List(); + if (wantRules) + { + var resolved = await ResolveBindTargetVersionAsync( + product, + RulesOperatorType, + input.RulesVersion!.Trim(), + "规则"); + if (!string.Equals(order.SalesProductRulesVersion, resolved, StringComparison.Ordinal)) + { + remarkParts.Add($"rules:{order.SalesProductRulesVersion ?? "-"}→{resolved}"); + order.SalesProductRulesVersion = resolved; + } + } + + if (wantPrice) + { + var resolved = await ResolveBindTargetVersionAsync( + product, + PriceOperatorType, + input.PriceVersion!.Trim(), + "价格"); + if (!string.Equals(order.SalesProductPriceVersion, resolved, StringComparison.Ordinal)) + { + remarkParts.Add($"price:{order.SalesProductPriceVersion ?? "-"}→{resolved}"); + order.SalesProductPriceVersion = resolved; + } + } + + if (remarkParts.Count == 0 && !input.RecalculateReceivable && !input.RecalculatePayable) + { + throw new BusinessException("目标版本与当前绑定相同,且未勾选重算"); + } + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + if (input.RecalculateReceivable) + { + EnsureReceivableMutable(order); + await RecalculateReceivableCoreAsync(order); + } + + if (input.RecalculatePayable) + { + await RecalculatePayableCoreAsync(order); + } + + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.BindSnapshotVersions, + beforeSnap, + afterSnap, + remarkParts.Count == 0 ? null : string.Join(';', remarkParts)); + return (await MapOrderOutputsAsync([order])).First(); + } + + /// + /// 解析绑定目标版本:CURRENT 取当前有效发布,否则按版本号校验存在。 + /// + private async Task ResolveBindTargetVersionAsync( + SalesProduct product, + string operatorType, + string versionOrCurrent, + string label) + { + if (string.Equals(versionOrCurrent, "CURRENT", StringComparison.OrdinalIgnoreCase)) + { + var effective = await SalesProductRepository.GetEffectivePublishRecordAsync( + product.Id, + operatorType, + DateTime.Now); + if (effective == null || string.IsNullOrWhiteSpace(effective.Version)) + { + throw new BusinessException($"未命中当前有效{label}发布版本"); + } + + return effective.Version.Trim(); + } + + var record = await SalesProductRepository.GetPublishRecordByVersionAsync( + product.Id, + operatorType, + versionOrCurrent); + if (record == null || string.IsNullOrWhiteSpace(record.Version)) + { + throw new BusinessException($"未找到{label}发布版本:{versionOrCurrent}"); + } + + return record.Version.Trim(); + } + /// /// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。 /// @@ -1010,6 +1131,7 @@ public partial class OrderService // 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。 // 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。 + // 锁渠道重算应付:允许渠道不在旧规则排货池内(渠道已确认,只重算买价)。 var snapshotBinding = enforceReallocateGate ? await ResolveSnapshotBindingAsync(product, DateTime.Now) : await ResolveSnapshotBindingFromOrderAsync(product, order); @@ -1032,7 +1154,14 @@ public partial class OrderService billing, chargingStage, schemeOverride, - forceChannelId); + forceChannelId, + allowDesignatedOutsidePool: lockCurrentChannel); + + // 重新排货成功后写回规则版本,使后续重算应付与当前排货池一致;卖价版本保持契约,不随排货改写。 + if (enforceReallocateGate) + { + order.SalesProductRulesVersion = snapshotBinding.RulesVersion; + } if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 2fd317d..8f7bc82 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -848,7 +848,8 @@ public partial class OrderService string chargingStage, string? _, string? schemeOverride = null, - int? forceServiceChannelId = null) + int? forceServiceChannelId = null, + bool allowDesignatedOutsidePool = false) { var evaluation = await EvaluateAllocationAsync( order, @@ -858,7 +859,8 @@ public partial class OrderService chargeableWeight, chargingStage, schemeOverride, - forceServiceChannelId); + forceServiceChannelId, + allowDesignatedOutsidePool); ApplyAllocationTraceToOrder(order, evaluation); @@ -928,7 +930,8 @@ public partial class OrderService decimal chargeableWeight, string chargingStage, string? schemeOverride, - int? forceServiceChannelId) + int? forceServiceChannelId, + bool allowDesignatedOutsidePool = false) { var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); @@ -985,27 +988,61 @@ public partial class OrderService if (designated.Count == 0) { var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value); - var failReason = poolRow == null - ? "指定渠道不在该销售产品排货池" - : "指定渠道不满足判断条件"; - if (poolRow != null - && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value)) + // 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。 + if (allowDesignatedOutsidePool) { - skipped.Add(new OrderAllocationSkippedOutput + var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x => + x.Id == forceServiceChannelId.Value); + if (lockedChannel == null) { - ServiceChannelId = forceServiceChannelId.Value, - ChannelCode = poolRow.ChannelCode, - ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName, - Stage = AllocationResolver.SkipStageCondition, - MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition), - Reason = failReason - }); + return BaseEval("订单服务渠道不存在,无法重算应付"); + } + + var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto + { + SalesProductId = product.Id, + ServiceChannelId = lockedChannel.Id, + ServiceChannelName = lockedChannel.ChannelName, + ProviderName = lockedChannel.ChannelName ?? lockedChannel.ChannelCode, + ChannelCode = lockedChannel.ChannelCode, + Weight = 0, + CustomerIds = [], + MatchCondition = null + }; + if (poolRow == null + || AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment(lockedProvider)) + { + await AllocationProviderSnapshotEnricher.EnrichAsync([lockedProvider]); + } + + filtered = [lockedProvider]; } + else + { + var failReason = poolRow == null + ? "指定渠道不在该销售产品排货池" + : "指定渠道不满足判断条件"; + if (poolRow != null + && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value)) + { + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = forceServiceChannelId.Value, + ChannelCode = poolRow.ChannelCode, + ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName, + Stage = AllocationResolver.SkipStageCondition, + MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition), + Reason = failReason + }); + } - return BaseEval(failReason); + return BaseEval(failReason); + } + } + else + { + filtered = designated; } - - filtered = designated; } if (filtered.Count == 0) @@ -1304,7 +1341,8 @@ public partial class OrderService OrderBillingContext billing, string chargingStage, string? schemeOverride = null, - int? forceServiceChannelId = null) => + int? forceServiceChannelId = null, + bool allowDesignatedOutsidePool = false) => ConfirmServiceChannelAndPayableAsync( order, product, @@ -1314,7 +1352,8 @@ public partial class OrderService chargingStage, billing.PriceTierKey, schemeOverride, - forceServiceChannelId); + forceServiceChannelId, + allowDesignatedOutsidePool); /// /// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。 @@ -1325,7 +1364,8 @@ public partial class OrderService OrderBillingContext billing, string chargingStage, string? schemeOverride = null, - int? forceServiceChannelId = null) + int? forceServiceChannelId = null, + bool allowDesignatedOutsidePool = false) { try { @@ -1335,7 +1375,8 @@ public partial class OrderService billing, chargingStage, schemeOverride, - forceServiceChannelId); + forceServiceChannelId, + allowDesignatedOutsidePool); return true; } catch (BusinessException ex) diff --git a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs index afb1ef4..4073363 100644 --- a/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs +++ b/src/Oms.Application/Shared/AllocationProviderSnapshotEnricher.cs @@ -108,6 +108,9 @@ public class AllocationProviderSnapshotEnricher( ServiceChannel? channelFallback = null) { order.DownstreamSupplierCode = NullIfEmpty(provider.SupplierCode); + order.DownstreamSupplierName = NullIfEmpty(provider.ApiServiceProviderName) + ?? NullIfEmpty(provider.GroupName) + ?? NullIfEmpty(provider.ProviderName); order.DownstreamAppKey = NullIfEmpty(provider.AppKey); order.DownstreamAppId = provider.AppId; order.ChannelForecastCode = NullIfEmpty(provider.ForecastCode) diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs index 866ac0d..17d6f8b 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -7,6 +7,7 @@ public static class OrderOperationActionCodes 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 { @@ -15,6 +16,7 @@ public static class OrderOperationActionCodes RecalcReceivable => "重算应收", RecalcPayable => "重算应付", Reallocate => "重新排货", + BindSnapshotVersions => "切换价格/规则版本", _ => action }; } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index 3aea651..2f2b77e 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -29,6 +29,12 @@ public class Order : OmsAuditedAggregateRoot /// public string? DownstreamSupplierCode { get; set; } + /// + /// 下游 API 服务商名称(itfx service provider name)。 + /// + [SugarColumn(Length = 128, IsNullable = true)] + public string? DownstreamSupplierName { get; set; } + /// /// 下游 itfx 应用 AppKey。 /// From dcd985bca418b03fe914c86977369d683b1d089b Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 10 Oct 2026 10:54:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E5=88=9B=E5=8D=95/=E5=87=BA?= =?UTF-8?q?=E5=85=A5=E5=BA=93=E5=90=8E=E7=BD=AE=E5=89=AF=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Fake.EventBus=EF=BC=8C=E9=A2=84=E6=8A=A5?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E6=8E=92=E8=B4=A7=E8=B5=B0=20MQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将轨迹、面单、9610、揽收、自动触发与 ForecastBilling 异步化,缩短扫码与下单 RT;队列未启用时同步回落。 Co-authored-by: Cursor --- .../Dtos/Orders/OrderParcelCreateOutput.cs | 24 +- .../SmallBagTrackOrderNotifier.cs | 72 ++- .../IOrderSideEffectEventPublisher.cs | 22 + .../OrderAutoTriggerFireEventHandler.cs | 48 ++ .../OrderAutoTriggerFireIntegrationEvent.cs | 19 + .../OrderLifecycleTrackAppendEventHandler.cs | 49 ++ ...derLifecycleTrackAppendIntegrationEvent.cs | 18 + .../OrderSideEffectEventPublisher.cs | 112 ++++ .../IOrderParcelCreateEventPublisher.cs | 46 ++ .../OrderParcelCreateEventPublisher.cs | 198 +++++++ ...rderParcelCustoms9610UpsertEventHandler.cs | 44 ++ ...ParcelCustoms9610UpsertIntegrationEvent.cs | 14 + .../OrderParcelLabelGenerateEventHandler.cs | 38 ++ ...rderParcelLabelGenerateIntegrationEvent.cs | 14 + .../OrderParcelPickupAttachEventHandler.cs | 51 ++ ...OrderParcelPickupAttachIntegrationEvent.cs | 26 + .../OrderParcelTrackUpsertEventHandler.cs | 76 +++ .../OrderParcelTrackUpsertIntegrationEvent.cs | 28 + .../OrderBatchMessagingServiceExtensions.cs | 4 + .../Operations/OutboundRecordService.cs | 14 +- .../Orders/Batch/OrderBatchActionExecutor.cs | 1 + .../Orders/Batch/OrderBatchJobItemFeedback.cs | 1 + .../Batch/OrderBatchOrderActionHandler.cs | 8 + .../Issues/AutoTriggerRuntimeService.cs | 24 +- .../Orders/OrderService.ParcelBilling.cs | 485 +++++++++++++----- .../Services/Orders/OrderService.cs | 28 +- .../Consts/OrderBatchJobStatusCodes.cs | 2 + 27 files changed, 1259 insertions(+), 207 deletions(-) create mode 100644 src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireIntegrationEvent.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendIntegrationEvent.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertIntegrationEvent.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateIntegrationEvent.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachIntegrationEvent.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs create mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertIntegrationEvent.cs diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs index 0f7f1fe..66875f0 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs @@ -41,12 +41,12 @@ public class OrderParcelCreateOutput public string? ConsigneeCountryName { get; set; } /// - /// 同步创建的揽收单号(上门揽收成功时)。 + /// 揽收单号(上门揽收;事件总线消费较快或同步回落时可能已有值)。 /// public string? PickupNo { get; set; } /// - /// 同步创建的揽收单 Id。 + /// 揽收单 Id。 /// public int? PickupOrderId { get; set; } @@ -59,4 +59,24 @@ public class OrderParcelCreateOutput /// 揽收单创建失败原因(运单已成功时仅作提示)。 /// public string? PickupCreateError { get; set; } + + /// + /// 揽收单已入事件总线、后台创建中(此时尚无 PickupNo)。 + /// + public bool PickupQueued { get; set; } + + /// + /// 订单状态(创单后可能仍为草稿,预报计费异步处理中)。 + /// + public string? OrderStatus { get; set; } + + /// + /// 预报失败/处理中原因。 + /// + public string? PredeclareFailReason { get; set; } + + /// + /// 预报计费/排货已入队或后台处理中。 + /// + public bool ForecastBillingQueued { get; set; } } diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs index 2f728b6..84a3c24 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Oms.Application.Messaging.OrderSideEffects; +using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Services.Orders.Batch; +using Oms.Domain.OrdersAggregate.Repositories; namespace Oms.Application.Integrations.SmallBagTrack; @@ -15,31 +18,42 @@ internal sealed class SmallBagTrackOrderNotifier( try { using var scope = scopeFactory.CreateScope(); - var coordinator = scope.ServiceProvider.GetRequiredService(); - var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId); - if (!published) + var orderRepository = scope.ServiceProvider.GetRequiredService(); + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null) { - await FallbackDirectAsync(request, orderId); + return; } + + var publisher = scope.ServiceProvider.GetRequiredService(); + await publisher.PublishTrackUpsertAsync(order, request, customerId); } catch (Exception ex) { logger.LogWarning( ex, - "RabbitMQ track upsert publish failed, fallback to direct call. orderId={OrderId}", + "Track upsert publish failed, fallback via batch coordinator. orderId={OrderId}", orderId); - await FallbackDirectAsync(request, orderId); + await FallbackBatchOrDirectAsync(request, orderId, customerId); } }); } - private async Task FallbackDirectAsync(SmallBagTrackUpsertRequest request, int orderId) + private async Task FallbackBatchOrDirectAsync( + SmallBagTrackUpsertRequest request, + int orderId, + int? customerId) { try { using var scope = scopeFactory.CreateScope(); - var client = scope.ServiceProvider.GetRequiredService(); - await client.UpsertAsync(request); + var coordinator = scope.ServiceProvider.GetRequiredService(); + var published = await coordinator.PublishTrackUpsertAsync(request, orderId, customerId); + if (!published) + { + var client = scope.ServiceProvider.GetRequiredService(); + await client.UpsertAsync(request); + } } catch (Exception ex) { @@ -49,23 +63,29 @@ internal sealed class SmallBagTrackOrderNotifier( public void EnqueueLifecycleTrackAppend(string orderNo, string trackCode, int orderId, string? authorization = null) { - _ = Task.Run(async () => + _ = PublishLifecycleAsync(orderNo, trackCode, orderId, authorization); + } + + private async Task PublishLifecycleAsync( + string orderNo, + string trackCode, + int orderId, + string? authorization) + { + try { - try - { - using var scope = scopeFactory.CreateScope(); - var client = scope.ServiceProvider.GetRequiredService(); - await client.AppendLifecycleTrackAsync(orderNo, trackCode, authorization); - } - catch (Exception ex) - { - logger.LogWarning( - ex, - "Async lifecycle track append failed. orderId={OrderId} orderNo={OrderNo} trackCode={TrackCode}", - orderId, - orderNo, - trackCode); - } - }); + using var scope = scopeFactory.CreateScope(); + var publisher = scope.ServiceProvider.GetRequiredService(); + await publisher.PublishLifecycleTrackAppendAsync(orderId, orderNo, trackCode, authorization); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Lifecycle track append publish failed. orderId={OrderId} orderNo={OrderNo} trackCode={TrackCode}", + orderId, + orderNo, + trackCode); + } } } diff --git a/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs new file mode 100644 index 0000000..9cba9f9 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs @@ -0,0 +1,22 @@ +using Oms.Application.Services.Orders.Issues; + +namespace Oms.Application.Messaging.OrderSideEffects; + +/// +/// 订单侧后置副作用发布(生命周期轨迹、自动触发等)。 +/// +public interface IOrderSideEffectEventPublisher +{ + Task PublishLifecycleTrackAppendAsync( + int orderId, + string orderNo, + string trackCode, + string? authorization = null, + CancellationToken cancellationToken = default); + + Task PublishAutoTriggerFireAsync( + int orderId, + AutoTriggerTiming timing, + int operatorUserId, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs new file mode 100644 index 0000000..670c52e --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireEventHandler.cs @@ -0,0 +1,48 @@ +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Orders.Issues; + +namespace Oms.Application.Messaging.OrderSideEffects; + +public sealed class OrderAutoTriggerFireEventHandler( + AutoTriggerRuntimeService autoTriggerRuntimeService, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + OrderAutoTriggerFireIntegrationEvent @event, + CancellationToken cancellationToken) + { + if (@event.OrderId <= 0 + || !Enum.TryParse(@event.Timing, ignoreCase: true, out var timing)) + { + logger.LogWarning( + "AutoTrigger fire skipped invalid payload OrderId={OrderId} Timing={Timing}", + @event.OrderId, + @event.Timing); + return; + } + + using var unitOfWork = unitOfWorkManager.Begin(); + try + { + await autoTriggerRuntimeService.FireByOrderIdAsync( + @event.OrderId, + timing, + @event.OperatorUserId); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "AutoTrigger fire failed OrderId={OrderId} Timing={Timing}", + @event.OrderId, + timing); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireIntegrationEvent.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireIntegrationEvent.cs new file mode 100644 index 0000000..f3d04c2 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderAutoTriggerFireIntegrationEvent.cs @@ -0,0 +1,19 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.OrderSideEffects; + +/// +/// 订单自动触发(下单 / 入库 / 出库时机),走 Fake.EventBus.RabbitMQ。 +/// +[Serializable] +public class OrderAutoTriggerFireIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + /// + /// 枚举名。 + /// + public string Timing { get; set; } = string.Empty; + + public int OperatorUserId { get; set; } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs new file mode 100644 index 0000000..2114097 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs @@ -0,0 +1,49 @@ +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.Logging; +using Oms.Application.Integrations.SmallBagTrack; + +namespace Oms.Application.Messaging.OrderSideEffects; + +public sealed class OrderLifecycleTrackAppendEventHandler( + ISmallBagTrackOrderClient trackClient, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + OrderLifecycleTrackAppendIntegrationEvent @event, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(@event.OrderNo) || string.IsNullOrWhiteSpace(@event.TrackCode)) + { + logger.LogWarning( + "Lifecycle track append skipped empty OrderNo/TrackCode OrderId={OrderId}", + @event.OrderId); + return; + } + + using var unitOfWork = unitOfWorkManager.Begin(); + try + { + await trackClient.AppendLifecycleTrackAsync( + @event.OrderNo, + @event.TrackCode, + @event.Authorization, + cancellationToken); + } + catch (Exception ex) + { + // 轨迹失败不反复打满队列;与原 Task.Run 吞异常策略一致 + logger.LogWarning( + ex, + "Lifecycle track append failed OrderId={OrderId} OrderNo={OrderNo} TrackCode={TrackCode}", + @event.OrderId, + @event.OrderNo, + @event.TrackCode); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendIntegrationEvent.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendIntegrationEvent.cs new file mode 100644 index 0000000..6144b5e --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendIntegrationEvent.cs @@ -0,0 +1,18 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.OrderSideEffects; + +/// +/// 订单生命周期轨迹追加(如入库 A0301、出库等),走 Fake.EventBus.RabbitMQ。 +/// +[Serializable] +public class OrderLifecycleTrackAppendIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + public string OrderNo { get; set; } = string.Empty; + + public string TrackCode { get; set; } = string.Empty; + + public string? Authorization { get; set; } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs new file mode 100644 index 0000000..078be98 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs @@ -0,0 +1,112 @@ +using Fake.EventBus.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Orders.Issues; + +namespace Oms.Application.Messaging.OrderSideEffects; + +/// +/// 优先 Fake.EventBus.RabbitMQ;发布失败则同进程同步执行 Handler。 +/// +public sealed class OrderSideEffectEventPublisher( + IDistributedEventBus eventBus, + IServiceScopeFactory scopeFactory, + ILogger logger) : IOrderSideEffectEventPublisher +{ + public async Task PublishLifecycleTrackAppendAsync( + int orderId, + string orderNo, + string trackCode, + string? authorization = null, + CancellationToken cancellationToken = default) + { + if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode)) + { + return; + } + + var @event = new OrderLifecycleTrackAppendIntegrationEvent + { + OrderId = orderId, + OrderNo = orderNo.Trim(), + TrackCode = trackCode.Trim(), + Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim() + }; + + if (await TryPublishAsync(@event, cancellationToken)) + { + return; + } + + await ExecuteInScopeAsync( + h => h.HandleAsync(@event, cancellationToken), + cancellationToken); + } + + public async Task PublishAutoTriggerFireAsync( + int orderId, + AutoTriggerTiming timing, + int operatorUserId, + CancellationToken cancellationToken = default) + { + if (orderId <= 0) + { + return; + } + + var @event = new OrderAutoTriggerFireIntegrationEvent + { + OrderId = orderId, + Timing = timing.ToString(), + OperatorUserId = operatorUserId + }; + + if (await TryPublishAsync(@event, cancellationToken)) + { + return; + } + + await ExecuteInScopeAsync( + h => h.HandleAsync(@event, cancellationToken), + cancellationToken); + } + + private async Task TryPublishAsync(IntegrationEvent @event, CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync(@event, cancellationToken); + logger.LogDebug( + "Published side-effect event {EventName} Id={EventId}", + @event.GetType().Name, + @event.Id); + return true; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Publish side-effect event {EventName} failed, fallback to sync. EventId={EventId}", + @event.GetType().Name, + @event.Id); + return false; + } + } + + private async Task ExecuteInScopeAsync( + Func action, + CancellationToken cancellationToken) + where THandler : notnull + { + try + { + using var scope = scopeFactory.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + await action(handler); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name); + } + } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs new file mode 100644 index 0000000..2545a2e --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs @@ -0,0 +1,46 @@ +using Oms.Application.Dtos.Orders; +using Oms.Application.Integrations.SmallBagTrack; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 创单后置副作用:发布到 Fake.EventBus.RabbitMQ;发布失败时回落同步执行。 +/// +public interface IOrderParcelCreateEventPublisher +{ + /// + /// 轨迹订单 + 创建成功轨迹(A0101)合并为一个事件。 + /// + Task PublishTrackUpsertAsync( + Order order, + SmallBagTrackUpsertRequest request, + int? customerId, + CancellationToken cancellationToken = default); + + Task PublishLabelGenerateAsync( + Order order, + int operatorUserId = 0, + CancellationToken cancellationToken = default); + + Task PublishCustoms9610UpsertAsync( + Order order, + string? platformCode = null, + CancellationToken cancellationToken = default); + + /// + /// 发布揽收挂接事件。返回 true 表示已入队;false 表示已同步回落执行(或参数不足未执行)。 + /// + Task PublishPickupAttachAsync( + OrderParcelCreateInput input, + int orderId, + CancellationToken cancellationToken = default); + + /// + /// 同步执行揽收挂接(用于创单 HTTP 需要回写 PickupNo 的场景)。 + /// + Task ExecutePickupAttachAsync( + OrderParcelCreateInput input, + int orderId, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs new file mode 100644 index 0000000..e265284 --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs @@ -0,0 +1,198 @@ +using Fake.EventBus.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Oms.Application.Dtos.Orders; +using Oms.Application.Integrations.SmallBagTrack; +using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 创单后置事件发布器:优先走 Fake.EventBus.RabbitMQ,失败则同进程同步执行 Handler 逻辑。 +/// +public sealed class OrderParcelCreateEventPublisher( + IDistributedEventBus eventBus, + IServiceScopeFactory scopeFactory, + ILogger logger) : IOrderParcelCreateEventPublisher +{ + public async Task PublishTrackUpsertAsync( + Order order, + SmallBagTrackUpsertRequest request, + int? customerId, + CancellationToken cancellationToken = default) + { + var @event = new OrderParcelTrackUpsertIntegrationEvent + { + OrderId = order.Id, + CustomerId = customerId, + PayloadJson = JsonConvert.SerializeObject(request), + AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess, + Authorization = request.Authorization + }; + + if (await TryPublishAsync(@event, cancellationToken)) + { + return; + } + + await ExecuteInScopeAsync( + h => h.HandleAsync(@event, cancellationToken), + cancellationToken); + } + + public async Task PublishLabelGenerateAsync( + Order order, + int operatorUserId = 0, + CancellationToken cancellationToken = default) + { + var @event = new OrderParcelLabelGenerateIntegrationEvent + { + OrderId = order.Id, + OperatorUserId = operatorUserId + }; + + if (await TryPublishAsync(@event, cancellationToken)) + { + return; + } + + await ExecuteInScopeAsync( + h => h.HandleAsync(@event, cancellationToken), + cancellationToken); + } + + public async Task PublishCustoms9610UpsertAsync( + Order order, + string? platformCode = null, + CancellationToken cancellationToken = default) + { + var @event = new OrderParcelCustoms9610UpsertIntegrationEvent + { + OrderId = order.Id, + PlatformCode = platformCode + }; + + if (await TryPublishAsync(@event, cancellationToken)) + { + return; + } + + await ExecuteInScopeAsync( + h => h.HandleAsync(@event, cancellationToken), + cancellationToken); + } + + public async Task PublishPickupAttachAsync( + OrderParcelCreateInput input, + int orderId, + CancellationToken cancellationToken = default) + { + var @event = BuildPickupEvent(input, orderId); + if (@event == null) + { + return false; + } + + // 揽收回落由调用方 ExecutePickupAttachAsync 同步执行,避免重复建单且便于回写单号。 + return await TryPublishAsync(@event, cancellationToken); + } + + public async Task ExecutePickupAttachAsync( + OrderParcelCreateInput input, + int orderId, + CancellationToken cancellationToken = default) + { + if (orderId <= 0 + || input.SenderAddressId is not > 0 + || input.ExpectPickupTime is null + || input.ExpectPickupTime == default + || input.WarehouseId is not > 0) + { + return null; + } + + using var scope = scopeFactory.CreateScope(); + var pickupService = scope.ServiceProvider.GetRequiredService(); + return await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput + { + CustomerId = input.CustomerId, + SenderAddressId = input.SenderAddressId.Value, + Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection), + WarehouseId = input.WarehouseId.Value, + CarrierCode = input.CarrierCode, + CarrierService = input.CarrierService, + ExpectPickupTime = input.ExpectPickupTime.Value, + OrderIds = [orderId], + Remark = null + }); + } + + private static OrderParcelPickupAttachIntegrationEvent? BuildPickupEvent( + OrderParcelCreateInput input, + int orderId) + { + if (orderId <= 0 + || input.SenderAddressId is not > 0 + || input.ExpectPickupTime is null + || input.ExpectPickupTime == default + || input.WarehouseId is not > 0) + { + return null; + } + + return new OrderParcelPickupAttachIntegrationEvent + { + OrderId = orderId, + CustomerId = input.CustomerId, + SenderAddressId = input.SenderAddressId.Value, + WarehouseId = input.WarehouseId.Value, + PickupDirection = input.PickupDirection, + CarrierCode = input.CarrierCode, + CarrierService = input.CarrierService, + ExpectPickupTime = input.ExpectPickupTime.Value + }; + } + + private async Task TryPublishAsync(IntegrationEvent @event, CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync(@event, cancellationToken); + logger.LogDebug( + "Published parcel-create event {EventName} Id={EventId}", + @event.GetType().Name, + @event.Id); + return true; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Publish parcel-create event {EventName} failed, fallback to sync. EventId={EventId}", + @event.GetType().Name, + @event.Id); + return false; + } + } + + private async Task ExecuteInScopeAsync( + Func action, + CancellationToken cancellationToken) + where THandler : notnull + { + try + { + using var scope = scopeFactory.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + await action(handler); + } + catch (Exception ex) + { + // 同步回落失败也不抛回创单主流程 + logger.LogWarning(ex, "Sync fallback for {Handler} failed", typeof(THandler).Name); + } + } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs new file mode 100644 index 0000000..bb292d2 --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs @@ -0,0 +1,44 @@ +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 处理创单后 9610 汇总申报订单写入事件。 +/// +public sealed class OrderParcelCustoms9610UpsertEventHandler( + IOrderRepository orderRepository, + Customs9610OrderWriter customs9610OrderWriter, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + OrderParcelCustoms9610UpsertIntegrationEvent @event, + CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(); + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken); + if (order == null) + { + logger.LogWarning("9610 upsert skipped, order missing OrderId={OrderId}", @event.OrderId); + return; + } + + try + { + await customs9610OrderWriter.UpsertFromOrderAsync(order, @event.PlatformCode, cancellationToken); + } + catch (Exception ex) + { + // 与原同步路径一致:9610 失败不抛到总线重试风暴 + logger.LogWarning(ex, "9610 upsert failed OrderId={OrderId}", @event.OrderId); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertIntegrationEvent.cs new file mode 100644 index 0000000..ef933ef --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertIntegrationEvent.cs @@ -0,0 +1,14 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 创单后置:按 9610 汇总申报写入/刷新 t_9610_order(非 9610 时 Handler 内会按规则清理 pending)。 +/// +[Serializable] +public class OrderParcelCustoms9610UpsertIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + public string? PlatformCode { get; set; } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs new file mode 100644 index 0000000..8e29720 --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateEventHandler.cs @@ -0,0 +1,38 @@ +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Orders; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 处理创单后系统面单生成事件。 +/// +public sealed class OrderParcelLabelGenerateEventHandler( + IServiceScopeFactory scopeFactory, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + OrderParcelLabelGenerateIntegrationEvent @event, + CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(); + using var scope = scopeFactory.CreateScope(); + var orderService = scope.ServiceProvider.GetRequiredService(); + var result = await orderService.GenerateLabelPdfForBatchAsync(@event.OrderId, cancellationToken); + if (!result.Success) + { + // 面单失败不阻断创单主链路;记录后正常 Ack,避免反复重试打满队列 + logger.LogWarning( + "Label generate failed OrderId={OrderId} Error={Error}", + @event.OrderId, + result.Error); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateIntegrationEvent.cs new file mode 100644 index 0000000..a7a807b --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelLabelGenerateIntegrationEvent.cs @@ -0,0 +1,14 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 创单后置:生成系统面单 PDF。 +/// +[Serializable] +public class OrderParcelLabelGenerateIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + public int OperatorUserId { get; set; } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs new file mode 100644 index 0000000..488e95c --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachEventHandler.cs @@ -0,0 +1,51 @@ +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.Logging; +using Oms.Application.Dtos.Orders; +using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Consts; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 处理创单后上门揽收挂接事件。 +/// +public sealed class OrderParcelPickupAttachEventHandler( + PickupOrderService pickupOrderService, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + OrderParcelPickupAttachIntegrationEvent @event, + CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(); + try + { + await pickupOrderService.CreateForParcelInternalAsync(new PickupOrderCreateInput + { + CustomerId = @event.CustomerId, + SenderAddressId = @event.SenderAddressId, + Direction = PickupDirectionCodes.NormalizeOrDefault(@event.PickupDirection), + WarehouseId = @event.WarehouseId, + CarrierCode = @event.CarrierCode, + CarrierService = @event.CarrierService, + ExpectPickupTime = @event.ExpectPickupTime, + OrderIds = [@event.OrderId], + Remark = null + }); + } + catch (Exception ex) + { + // 与原同步路径一致:揽收失败不回滚物流单,仅记日志(列表可补建) + logger.LogWarning( + ex, + "Pickup attach failed OrderId={OrderId}", + @event.OrderId); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachIntegrationEvent.cs new file mode 100644 index 0000000..18afd3d --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelPickupAttachIntegrationEvent.cs @@ -0,0 +1,26 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 创单后置:上门揽收时创建并挂接揽收订单(可选再预报承运)。 +/// +[Serializable] +public class OrderParcelPickupAttachIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + public int? CustomerId { get; set; } + + public int SenderAddressId { get; set; } + + public int WarehouseId { get; set; } + + public string? PickupDirection { get; set; } + + public string? CarrierCode { get; set; } + + public string? CarrierService { get; set; } + + public DateTime ExpectPickupTime { get; set; } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs new file mode 100644 index 0000000..6347a3c --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs @@ -0,0 +1,76 @@ +using Fake.Application; +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Oms.Application.Integrations.SmallBagTrack; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 处理「轨迹订单 + 创建成功轨迹」合并事件。 +/// +public sealed class OrderParcelTrackUpsertEventHandler( + IOrderRepository orderRepository, + ISmallBagTrackOrderClient trackClient, + ISmallBagTrackUpsertRequestBuilder requestBuilder, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + OrderParcelTrackUpsertIntegrationEvent @event, + CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(); + + var request = await ResolveRequestAsync(@event, cancellationToken); + request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess; + if (string.IsNullOrWhiteSpace(request.Authorization) + && !string.IsNullOrWhiteSpace(@event.Authorization)) + { + request.Authorization = @event.Authorization.Trim(); + } + + var result = await trackClient.UpsertDebugAsync(request, cancellationToken); + if (result.Skipped) + { + logger.LogInformation( + "Track upsert skipped OrderId={OrderId} Reason={Reason}", + @event.OrderId, + result.SkipReason); + } + else if (!result.Success) + { + throw new BusinessException( + $"轨迹订单创建失败: HTTP {result.StatusCode}"); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } + + private async Task ResolveRequestAsync( + OrderParcelTrackUpsertIntegrationEvent @event, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(@event.PayloadJson)) + { + var fromPayload = JsonConvert.DeserializeObject(@event.PayloadJson); + if (fromPayload != null && !string.IsNullOrWhiteSpace(fromPayload.OrderNo)) + { + return fromPayload; + } + } + + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken) + ?? throw new BusinessException("订单不存在"); + if (string.IsNullOrWhiteSpace(order.ServiceNo)) + { + throw new BusinessException("订单运单号为空,无法同步轨迹"); + } + + return await requestBuilder.BuildAsync(order, cancellationToken: cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertIntegrationEvent.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertIntegrationEvent.cs new file mode 100644 index 0000000..214c668 --- /dev/null +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertIntegrationEvent.cs @@ -0,0 +1,28 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.ParcelCreate; + +/// +/// 创单后置:新增轨迹订单,并在成功后追加「创建订单成功」轨迹(A0101)。 +/// 二者合并为同一集成事件,由 Handler 内 UpsertSmallBag + AppendInitialTrackOnSuccess 一次完成。 +/// +[Serializable] +public class OrderParcelTrackUpsertIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + public int? CustomerId { get; set; } + + /// + /// 序列化后的 ; + /// 为空时 Handler 按 OrderId 重建。 + /// + public string? PayloadJson { get; set; } + + /// + /// 是否在 Upsert 成功后追加创单默认轨迹 A0101。 + /// + public bool AppendInitialTrackOnSuccess { get; set; } = true; + + public string? Authorization { get; set; } +} diff --git a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs index 24960a7..8131be4 100644 --- a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs +++ b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Oms.Application.Messaging; +using Oms.Application.Messaging.OrderSideEffects; +using Oms.Application.Messaging.ParcelCreate; namespace Oms.Application; @@ -11,6 +13,8 @@ public static class OrderBatchMessagingServiceExtensions services.Configure(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管 services.AddHostedService(); return services; diff --git a/src/Oms.Application/Services/Operations/OutboundRecordService.cs b/src/Oms.Application/Services/Operations/OutboundRecordService.cs index 30f84ff..b64d4b7 100644 --- a/src/Oms.Application/Services/Operations/OutboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/OutboundRecordService.cs @@ -161,18 +161,8 @@ public class OutboundRecordService : ApplicationService var savedEntity = await OutboundRecordRepository.InsertAsync(entity); - try - { - var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim()); - if (refreshed != null) - { - await AutoTriggerRuntimeService.FireAsync(refreshed, AutoTriggerTiming.Outbound, operatorId); - } - } - catch - { - // 自动触发失败不影响出库主流程 - } + // 自动触发改走 Fake.EventBus,避免拉长出库扫码 RT + AutoTriggerRuntimeService.EnqueueFire(order.Id, AutoTriggerTiming.Outbound, operatorId); return ObjectMapper.Map(savedEntity); } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs index 913cd7a..d74cfe6 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs @@ -49,6 +49,7 @@ public sealed class OrderBatchActionExecutor( OrderBatchActionCodes.Print => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.GenerateLabelPdf => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.InboundBilling => await orderActionHandler.ExecuteAsync(message, cancellationToken), + OrderBatchActionCodes.ForecastBilling => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.Reallocate => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken), diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs index b8f75fd..703bf09 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs @@ -269,6 +269,7 @@ public static class OrderBatchJobItemFeedback OrderBatchActionCodes.Print => "打印成功", OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功", OrderBatchActionCodes.InboundBilling => "入库计费/排货完成", + OrderBatchActionCodes.ForecastBilling => "预报计费/排货完成", OrderBatchActionCodes.Reallocate => "重新排货成功", OrderBatchActionCodes.MarkProblematic => "问题件创建成功", OrderBatchActionCodes.MarkClaimed => "理赔件创建成功", diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs index f578a60..f363cd8 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs @@ -67,6 +67,14 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto await orderService.ProcessInboundBillingForBatchAsync(message.OrderId, cancellationToken); return OrderBatchActionExecuteResult.Ok(); + case OrderBatchActionCodes.ForecastBilling: + await orderService.ProcessForecastBillingForBatchAsync( + message.OrderId, + message.PayloadJson, + message.OperatorUserId, + cancellationToken); + return OrderBatchActionExecuteResult.Ok(); + case OrderBatchActionCodes.Reallocate: await orderService.ReallocateForBatchAsync( message.OrderId, diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index e8276d1..9b70856 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -1,6 +1,6 @@ using Fake.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Oms.Application.Messaging.OrderSideEffects; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -16,13 +16,13 @@ public class AutoTriggerRuntimeService( OrderAutoTriggerService orderAutoTriggerService, OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, - IServiceScopeFactory scopeFactory, + IOrderSideEffectEventPublisher sideEffectEventPublisher, ILogger logger) : IScopedDependency { private const string InterceptOperation = "拦截"; /// - /// 后台异步触发,避免拉长入库/出库扫码请求。 + /// 后台异步触发(Fake.EventBus.RabbitMQ),避免拉长入库/出库/创单请求。 /// public void EnqueueFire(int orderId, AutoTriggerTiming timing, int operatorUserId) { @@ -31,23 +31,7 @@ public class AutoTriggerRuntimeService( return; } - _ = Task.Run(async () => - { - try - { - using var scope = scopeFactory.CreateScope(); - var runtime = scope.ServiceProvider.GetRequiredService(); - await runtime.FireByOrderIdAsync(orderId, timing, operatorUserId); - } - catch (Exception ex) - { - logger.LogWarning( - ex, - "异步自动触发失败 OrderId={OrderId} Timing={Timing}", - orderId, - timing); - } - }); + _ = sideEffectEventPublisher.PublishAutoTriggerFireAsync(orderId, timing, operatorUserId); } public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId) diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 8f7bc82..8dc9c39 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -9,6 +9,8 @@ using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Labeling; using Oms.Application.Messaging; +using Oms.Application.Messaging.OrderSideEffects; +using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Application.Services.Orders.Batch; @@ -36,6 +38,8 @@ public partial class OrderService private const int ParcelBatchMaxCount = 2000; private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; + private const string ForecastBillingProcessingReason = "预报计费/排货处理中"; + private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报"; private static readonly ConcurrentDictionary InboundBillingInFlight = new(); @@ -143,10 +147,11 @@ public partial class OrderService // 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。 if (input.SaveAsDraft) { - await TryUpsertCustoms9610Async(order, input.PlatformCode); + await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); return await BuildParcelCreateOutputAsync(order); } + // 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。 order.IsRemote = await IsRemoteHitAsync( product, snapshotBinding.Rules, @@ -154,62 +159,24 @@ public partial class OrderService input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode) ? (byte)1 : (byte)0; - - var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast); - await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); - SyncWeightRows(weights, forecast); - await OrderRepository.ReplaceWeightsAsync(order.Id, weights); - await TryUpsertCustoms9610Async(order, input.PlatformCode); - - var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId); - var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId); - var billing = await ResolveBillingContextAsync( - product, - snapshotBinding, - new OrderBillingAddress( - country.CountryCode, - input.Receiver?.Province, - input.Receiver?.City, - input.Receiver?.ZipCode), - forecast.ChargeableWeight ?? 0, - order.CargoAttribute ?? CargoAttributeCodes.General, - order.WarehouseId, - priceTierKey); - - await UpsertReceivableFeesAsync( - order, - billing, - OrderFeeChargingStageCodes.Forecast); - - // 预报阶段尝试排货写应付;失败不阻断创建(仅记录 AllocationPendingReason)。 - await TryConfirmServiceChannelAndPayableAsync( - order, - product, - billing, - OrderFeeChargingStageCodes.Forecast); - - await ApplyMarketingSnapshotOnCreateAsync(order, input.CouponInstanceId); - await CaptureForecastBillingSnapshotAsync(order); - - order.OrderStatus = OrderStatusCodes.Predeclared; - order.PredeclareFailReason = string.Empty; + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = ForecastBillingProcessingReason; + order.AllocationPendingReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver, appendInitialTrack: true); - await EnqueueGenerateLabelPdfAsync(order); + await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); + await EnqueueForecastBillingAsync( + order, + input.CouponInstanceId, + operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - try - { - var triggerUserId = operatorUserId ?? CurrentUser.GetId(); - await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId); - } - catch - { - // 自动触发失败不影响预报主流程 - } - - return await BuildParcelCreateOutputAsync(order); + var output = await BuildParcelCreateOutputAsync(order); + output.ForecastBillingQueued = string.Equals( + order.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal); + return output; } catch (Exception ex) { @@ -2287,8 +2254,7 @@ public partial class OrderService } /// - /// 草稿重新预报:按订单当前收件/件重尺/快照重跑黑名单、计重、分区与预报应收。 - /// 失败时保持草稿并写入 PredeclareFailReason。 + /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。 /// internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null) { @@ -2319,63 +2285,52 @@ public partial class OrderService throw new BusinessException("收件地址命中黑名单区域"); } - order.IsRemote = await IsRemoteHitAsync( - product, - snapshotBinding.Rules, - address.CountryCode, - address.Province, - address.City, - address.ZipCode) ? (byte)1 : (byte)0; + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + if (!weights.Any(x => + string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase))) + { + throw new BusinessException("订单缺少预报件重尺"); + } - var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) - .OrderBy(x => x.Id) - .ToList(); - var forecast = weights.FirstOrDefault(x => - string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase)) - ?? throw new BusinessException("订单缺少预报件重尺"); - - await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); - SyncWeightRows(weights, forecast); - await OrderRepository.ReplaceWeightsAsync(order.Id, weights); - - var billing = await ResolveOrderBillingContextAsync( - order, - product, - forecast.ChargeableWeight ?? 0, - snapshotBinding); - - await UpsertReceivableFeesAsync( - order, - billing, - OrderFeeChargingStageCodes.Forecast, - replaceExistingReceivable: true); - - order.OrderStatus = OrderStatusCodes.Predeclared; - order.PredeclareFailReason = string.Empty; + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = ForecastBillingProcessingReason; order.AllocationPendingReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - var customer = order.CustomerId.HasValue - ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) - : null; - await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null, appendInitialTrack: true); - await EnqueueGenerateLabelPdfAsync(order); + await EnqueueForecastBillingAsync( + order, + order.CouponInstanceId, + operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - try + // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 + var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); + if (refreshed != null + && string.Equals(refreshed.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(refreshed.PredeclareFailReason) + && !string.Equals( + refreshed.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal)) { - var triggerUserId = operatorUserId ?? CurrentUser.GetId(); - await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId); - } - catch - { - // 自动触发失败不影响预报主流程 + throw CreateDraftSavedBusinessException( + refreshed, + StripDraftSavedMarker(refreshed.PredeclareFailReason)); } } catch (BusinessException ex) { + if (string.Equals(ex.ErrorCode, OrderParcelDraftFailData.ErrorCodeValue, StringComparison.Ordinal)) + { + throw; + } + var cleanReason = StripDraftSavedMarker(ex.Message); - await MarkParcelDraftFailedAsync(order, cleanReason); + if (!string.Equals(cleanReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) + { + await MarkParcelDraftFailedAsync(order, cleanReason); + } + throw CreateDraftSavedBusinessException(order, cleanReason); } catch (Exception ex) @@ -2464,7 +2419,7 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode); + await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.InboundTrackCode); await EnqueueInboundBillingAsync(order); } finally @@ -2646,6 +2601,256 @@ public partial class OrderService return text[..512]; } + /// + /// 预报计费/排货异步任务(创单与草稿预报共用)。 + /// + internal async Task ProcessForecastBillingForBatchAsync( + int orderId, + string? payloadJson = null, + int operatorUserId = 0, + CancellationToken cancellationToken = default) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); + if (order == null) + { + return; + } + + // 已预报成功则跳过(幂等) + if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase) + && string.IsNullOrWhiteSpace(order.PredeclareFailReason)) + { + return; + } + + int? couponInstanceId = order.CouponInstanceId; + if (!string.IsNullOrWhiteSpace(payloadJson)) + { + try + { + var payload = JsonConvert.DeserializeObject(payloadJson); + if (payload?.CouponInstanceId is > 0) + { + couponInstanceId = payload.CouponInstanceId; + } + } + catch + { + // ignore bad payload + } + } + + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + await MarkParcelDraftFailedAsync(order, "订单未绑定销售产品,无法预报"); + return; + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null || product.IsActive != EnableStatus.Enabled) + { + await MarkParcelDraftFailedAsync(order, "销售产品不存在或未启用"); + return; + } + + ProductSnapshotBinding snapshotBinding; + OrderBillingAddress address; + try + { + snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + address = await ResolveOrderBillingAddressAsync(order); + order.IsRemote = await IsRemoteHitAsync( + product, + snapshotBinding.Rules, + address.CountryCode, + address.Province, + address.City, + address.ZipCode) ? (byte)1 : (byte)0; + } + catch (Exception ex) + { + await MarkParcelDraftFailedAsync( + order, + ex is BusinessException be ? be.Message : $"预报快照解析失败:{ex.Message}"); + return; + } + + var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) + .OrderBy(x => x.Id) + .ToList(); + var forecast = weights.FirstOrDefault(x => + string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase)); + if (forecast == null) + { + await MarkParcelDraftFailedAsync(order, "订单缺少预报件重尺"); + return; + } + + try + { + await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); + SyncWeightRows(weights, forecast); + await OrderRepository.ReplaceWeightsAsync(order.Id, weights); + } + catch (Exception ex) + { + await MarkParcelDraftFailedAsync( + order, + ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}"); + return; + } + + OrderBillingContext billing; + try + { + billing = await ResolveOrderBillingContextAsync( + order, + product, + forecast.ChargeableWeight ?? 0, + snapshotBinding); + } + catch (Exception ex) + { + await MarkParcelDraftFailedAsync( + order, + ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}"); + return; + } + + try + { + await UpsertReceivableFeesAsync( + order, + billing, + OrderFeeChargingStageCodes.Forecast, + replaceExistingReceivable: true); + } + catch (Exception ex) + { + await MarkParcelDraftFailedAsync( + order, + ex is BusinessException be ? be.Message : $"预报应收计费失败:{ex.Message}"); + return; + } + + // 排货失败不阻断预报成功(与原创单同步路径一致) + await TryConfirmServiceChannelAndPayableAsync( + order, + product, + billing, + OrderFeeChargingStageCodes.Forecast); + + try + { + await ApplyMarketingSnapshotOnCreateAsync(order, couponInstanceId); + } + catch (Exception ex) + { + var logger = LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.ForecastBilling"); + logger.LogWarning(ex, "预报营销快照失败 OrderId={OrderId}", order.Id); + } + + try + { + await CaptureForecastBillingSnapshotAsync(order); + } + catch + { + // ignore + } + + order.OrderStatus = OrderStatusCodes.Predeclared; + order.PredeclareFailReason = string.Empty; + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + var customer = order.CustomerId.HasValue + ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) + : null; + await PublishParcelCreateTrackAndLabelAsync( + order, + customer, + receiver: null, + appendInitialTrack: true, + operatorUserId); + + AutoTriggerRuntimeService.EnqueueFire( + order.Id, + AutoTriggerTiming.OrderCreate, + operatorUserId); + } + + private sealed class ForecastBillingJobPayload + { + public int? CouponInstanceId { get; set; } + } + + private async Task EnqueueForecastBillingAsync( + Order order, + int? couponInstanceId, + int operatorUserId) + { + var logger = LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.ForecastBilling"); + var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload + { + CouponInstanceId = couponInstanceId + }); + + try + { + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + try + { + await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorUserId); + } + catch (Exception ex) + { + logger.LogError( + ex, + "预报计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}", + order.Id, + order.ServiceNo); + await MarkParcelDraftFailedAsync( + order, + TrimPendingReason($"预报计费失败:{ex.Message}")); + } + + return; + } + + var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}"; + await BatchJobCoordinator.CreateAndEnqueueAsync( + new OrderBatchJobCreateInput + { + Action = OrderBatchActionCodes.ForecastBilling, + OrderIds = [order.Id], + RequestId = requestId, + PayloadJson = payloadJson + }, + operatorUserId); + } + catch (Exception ex) + { + logger.LogError( + ex, + "预报计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}", + order.Id, + order.ServiceNo); + try + { + await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); + } + catch + { + // ignore + } + } + } + private async Task EnqueueInboundBillingAsync(Order order) { var logger = LazyServiceProvider.GetRequiredService() @@ -2819,7 +3024,7 @@ public partial class OrderService order.UpdateTime = now; await OrderRepository.UpdateAsync(order); - EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.OutboundTrackCode); + await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.OutboundTrackCode); } private async Task BuildParcelCreateOutputAsync(Order order) @@ -2877,46 +3082,56 @@ public partial class OrderService TrackNo = order.TrackNo, ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountryName = order.ConsigneeCountryName, - LabelUrl = labelUrl + LabelUrl = labelUrl, + OrderStatus = order.OrderStatus, + PredeclareFailReason = order.PredeclareFailReason, + ForecastBillingQueued = string.Equals( + order.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal) }; } - private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier => - LazyServiceProvider.GetRequiredService(); + private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher => + LazyServiceProvider.GetRequiredService(); - private async Task EnqueueGenerateLabelPdfAsync(Order order) + private IOrderSideEffectEventPublisher SideEffectEventPublisher => + LazyServiceProvider.GetRequiredService(); + + /// + /// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。 + /// + private async Task PublishParcelCreateTrackAndLabelAsync( + Order order, + Customer? customer, + OrderParcelReceiverInput? receiver, + bool appendInitialTrack, + int? operatorUserId = null) { - try + var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); + request.AppendInitialTrackOnSuccess = appendInitialTrack; + var authorization = LazyServiceProvider + .GetRequiredService() + .HttpContext? + .Request + .Headers + .Authorization + .ToString(); + if (!string.IsNullOrWhiteSpace(authorization)) { - var publisher = LazyServiceProvider.GetRequiredService(); - if (!publisher.IsEnabled) - { - // 本地未开队列:同步生成,避免 Pending 悬挂 - var mapped = (await MapOrderOutputsAsync([order])).First(); - await LabelPdfGenerationService.GenerateAndPersistAsync( - order, - mapped, - operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); - return; - } + request.Authorization = authorization; + } - await BatchJobCoordinator.CreateAndEnqueueAsync( - new OrderBatchJobCreateInput - { - Action = OrderBatchActionCodes.GenerateLabelPdf, - OrderIds = [order.Id], - RequestId = $"label-pdf:{order.Id}:{Guid.NewGuid():N}" - }, - CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); - } - catch (Exception) - { - // 投递失败不阻断创单;打印时会补生成 - } + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + await ParcelCreateEventPublisher.PublishLabelGenerateAsync( + order, + operatorUserId + ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); } /// /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 + /// 入库等非创单路径仍可调用;创单路径请走 。 /// private async Task EnqueueSmallBagTrackUpsertAsync( Order order, @@ -2938,10 +3153,10 @@ public partial class OrderService request.Authorization = authorization; } - SmallBagTrackOrderNotifier.Enqueue(request, order.Id, customer?.Id); + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } - private void EnqueueLifecycleTrackAppend(Order order, string trackCode) + private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode) { if (string.IsNullOrWhiteSpace(order.ServiceNo)) { @@ -2956,10 +3171,10 @@ public partial class OrderService .Authorization .ToString(); - SmallBagTrackOrderNotifier.EnqueueLifecycleTrackAppend( + await SideEffectEventPublisher.PublishLifecycleTrackAppendAsync( + order.Id, order.ServiceNo, trackCode, - order.Id, string.IsNullOrWhiteSpace(authorization) ? null : authorization); } diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 9d33c6c..35a155d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -11,6 +11,7 @@ using Oms.Application.Labeling; using Oms.Application.Shared; using Oms.Application.Storage; using Newtonsoft.Json; +using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Services.Orders.Batch; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories; @@ -660,21 +661,24 @@ public partial class OrderService : ApplicationService return; } + var publisher = LazyServiceProvider.GetRequiredService(); try { - var pickupService = LazyServiceProvider.GetRequiredService(); - var pickup = await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput + // 优先入 Fake.EventBus;发布失败则同步回落建揽收并回写单号。 + var queued = await publisher.PublishPickupAttachAsync(input, output.OrderId); + if (queued) { - CustomerId = input.CustomerId, - SenderAddressId = input.SenderAddressId.Value, - Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection), - WarehouseId = input.WarehouseId.Value, - CarrierCode = input.CarrierCode, - CarrierService = input.CarrierService, - ExpectPickupTime = input.ExpectPickupTime.Value, - OrderIds = [output.OrderId], - Remark = null - }); + output.PickupQueued = true; + return; + } + + var pickup = await publisher.ExecutePickupAttachAsync(input, output.OrderId); + if (pickup == null) + { + output.PickupCreateError = "揽收单创建失败"; + return; + } + output.PickupNo = pickup.PickupNo; output.PickupOrderId = pickup.Id; output.CarrierWaybillNo = pickup.CarrierWaybillNo; diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs index da96b64..e6250a6 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs @@ -33,6 +33,8 @@ public static class OrderBatchActionCodes public const string Print = "print"; public const string GenerateLabelPdf = "generate-label-pdf"; public const string InboundBilling = "inbound-billing"; + /// 创单/草稿预报:预报应收计费 + 排货应付。 + public const string ForecastBilling = "forecast-billing"; public const string Reallocate = "reallocate"; public const string Export = "export"; public const string ParcelCreate = "parcel-create"; From 64737284f3d5d51c2f53bb733530baef00e7e6b6 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 10 Oct 2026 11:46:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E8=BF=90=E5=8D=95=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E5=BF=97=E6=94=B9=E4=B8=BA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=91=98=E8=A6=81=EF=BC=8C=E8=A1=A5=E9=BD=90=E8=B4=B9=E7=94=A8?= =?UTF-8?q?/=E6=94=AF=E4=BB=98/=E9=A2=84=E6=8A=A5/=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E5=9F=8B=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- sql/migrate_order_operation_log.sql | 2 +- .../Dtos/Orders/OrderOperationLogDtos.cs | 5 ++ .../Orders/Batch/OrderBatchActionExecutor.cs | 26 ++++++ .../Orders/OrderOperationLogSnapshot.cs | 89 +++++++++++++++++++ .../Orders/OrderOperationLogWriter.cs | 87 +++++++++++++++--- .../Services/Orders/OrderService.OrderEdit.cs | 32 ++++++- .../Orders/OrderService.ParcelBilling.cs | 14 +++ .../Services/Orders/OrderService.cs | 39 ++++++++ .../Consts/OrderOperationActionCodes.cs | 12 +++ 9 files changed, 289 insertions(+), 17 deletions(-) diff --git a/sql/migrate_order_operation_log.sql b/sql/migrate_order_operation_log.sql index cd0aba0..094b880 100644 --- a/sql/migrate_order_operation_log.sql +++ b/sql/migrate_order_operation_log.sql @@ -17,7 +17,7 @@ SET @sql := IF( `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'', + `Action` VARCHAR(32) NOT NULL COMMENT ''动作码 editBasic/editWeight/recalcReceivable/recalcPayable/reallocate/bindSnapshotVersions/updateAdditionalServices/addFee/pay/preDeclare/downstreamForward/print'', `ActionName` VARCHAR(64) NOT NULL COMMENT ''动作中文名'', `OperatorId` INT NOT NULL DEFAULT 0 COMMENT ''操作人用户Id'', `OperatorName` VARCHAR(64) NULL COMMENT ''操作人展示名'', diff --git a/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs index 3b787e0..c5a3c84 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOperationLogDtos.cs @@ -35,6 +35,11 @@ public class OrderOperationLogOutput public string? Remark { get; set; } + /// + /// 列表「内容」列:由备注 + 字段变更摘要拼装。 + /// + public string Summary { get; set; } = string.Empty; + public DateTime CreateTime { get; set; } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs index d74cfe6..b5c9283 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs @@ -26,6 +26,7 @@ public sealed class OrderBatchActionExecutor( OrderDownstreamCredentialResolver downstreamCredentialResolver, IItfxForwardClient itfxForwardClient, OrderFileManager orderFileManager, + OrderOperationLogWriter operationLogWriter, OrderBatchCreateActionHandler createActionHandler, OrderBatchOrderActionHandler orderActionHandler) : IOrderBatchActionExecutor, IScopedDependency { @@ -225,6 +226,8 @@ public sealed class OrderBatchActionExecutor( businessMessage)); } + var beforeSnap = OrderOperationLogSnapshot.CaptureOrder(order); + if (ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data) && data != null) { @@ -243,6 +246,29 @@ public sealed class OrderBatchActionExecutor( } } + var afterSnap = OrderOperationLogSnapshot.CaptureOrder(order); + var remarkParts = new List { $"供应商 {creds.SupplierCode}" }; + if (!string.IsNullOrWhiteSpace(order.DownstreamServiceNo)) + { + remarkParts.Add($"服务商单号 {order.DownstreamServiceNo}"); + } + + if (!string.IsNullOrWhiteSpace(order.TrackNo)) + { + remarkParts.Add($"跟踪号 {order.TrackNo}"); + } + + await operationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.DownstreamForward, + beforeSnap, + afterSnap, + string.Join(";", remarkParts), + message.OperatorUserId > 0 ? message.OperatorUserId : null, + operatorName: null, + operatorType: "staff", + source: "job"); + return OrderBatchActionExecuteResult.Ok( serviceNo: order.ServiceNo, trackNo: order.TrackNo, diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs index 527471a..f37c5d4 100644 --- a/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs +++ b/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs @@ -1,4 +1,6 @@ using System.Globalization; +using Newtonsoft.Json; +using Oms.Application.Shared; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -9,6 +11,8 @@ namespace Oms.Application.Services.Orders; /// public static class OrderOperationLogSnapshot { + public const int SummaryMaxChangeItems = 5; + private static readonly Dictionary Labels = new(StringComparer.OrdinalIgnoreCase) { ["orderStatus"] = "订单状态", @@ -31,6 +35,7 @@ public static class OrderOperationLogSnapshot ["salesProductRulesVersion"] = "规则版本", ["salesProductPriceVersion"] = "价格版本", ["isPaid"] = "是否已支付", + ["additionalServices"] = "附加服务", ["sender.name"] = "发件人姓名", ["sender.phone"] = "发件人电话", ["sender.country"] = "发件国家", @@ -75,6 +80,7 @@ public static class OrderOperationLogSnapshot ["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion), ["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion), ["isPaid"] = FormatByte(order.IsPaid), + ["additionalServices"] = FormatAdditionalServices(order.AdditionalServices), }; } @@ -225,6 +231,89 @@ public static class OrderOperationLogSnapshot return path; } + /// + /// 拼装列表「内容」列:备注优先,再跟最多 N 条字段变更。 + /// + public static string BuildSummary( + IReadOnlyList changes, + string? remark, + int maxItems = SummaryMaxChangeItems) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(remark)) + { + parts.Add(remark.Trim()); + } + + if (changes.Count > 0) + { + var take = Math.Max(0, maxItems); + foreach (var change in changes.Take(take)) + { + var label = string.IsNullOrWhiteSpace(change.Label) ? change.Path : change.Label; + parts.Add($"{label}:{DisplayValue(change.Path, change.Before)}→{DisplayValue(change.Path, change.After)}"); + } + + if (changes.Count > take) + { + parts.Add($"等{changes.Count}项变更"); + } + } + + return parts.Count == 0 ? "—" : string.Join(";", parts); + } + + public readonly record struct OrderOperationLogChangeView( + string Path, + string Label, + string? Before, + string? After); + + private static string DisplayValue(string path, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "空"; + } + + if (string.Equals(path, "isPaid", StringComparison.OrdinalIgnoreCase) + || string.Equals(path, "isCod", StringComparison.OrdinalIgnoreCase)) + { + return value == "1" ? "是" : "否"; + } + + if (string.Equals(path, "orderStatus", StringComparison.OrdinalIgnoreCase)) + { + return OmsDocHelper.GetOrderStatusName(value) ?? value; + } + + return value; + } + + private static string? FormatAdditionalServices(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + var list = JsonConvert.DeserializeObject>(json) ?? []; + var codes = list + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + return codes.Count == 0 ? null : string.Join("、", codes); + } + catch + { + return Norm(json); + } + } + private static string? Norm(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs index d5b3aea..3a6cd6b 100644 --- a/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs +++ b/src/Oms.Application/Services/Orders/OrderOperationLogWriter.cs @@ -33,23 +33,29 @@ public class OrderOperationLogWriter : IScopedDependency _logger = logger; } - public async Task TryWriteAsync( + public Task TryWriteAsync( Order order, string action, IReadOnlyDictionary before, IReadOnlyDictionary after, string? remark = null) + => TryWriteAsync(order, action, before, after, remark, operatorId: null, operatorName: null); + + public async Task TryWriteAsync( + Order order, + string action, + IReadOnlyDictionary before, + IReadOnlyDictionary after, + string? remark, + int? operatorId, + string? operatorName, + string operatorType = "staff", + string source = "pc") { try { var changes = OrderOperationLogSnapshot.Diff(before, after); - var operatorId = 0; - string? operatorName = null; - if (_currentUser.IsAuthenticated) - { - operatorId = _currentUser.GetId(); - operatorName = await _userDisplayNameResolver.ResolveAsync(operatorId); - } + var (resolvedId, resolvedName) = await ResolveOperatorAsync(operatorId, operatorName); var entity = new OrderOperationLog { @@ -57,10 +63,10 @@ public class OrderOperationLogWriter : IScopedDependency ServiceNo = order.ServiceNo, Action = action, ActionName = OrderOperationActionCodes.GetActionName(action), - OperatorId = operatorId, - OperatorName = operatorName, - OperatorType = "staff", - Source = "pc", + OperatorId = resolvedId, + OperatorName = resolvedName, + OperatorType = string.IsNullOrWhiteSpace(operatorType) ? "staff" : operatorType.Trim(), + Source = string.IsNullOrWhiteSpace(source) ? "pc" : source.Trim(), ChangesJson = JsonConvert.SerializeObject( changes.Select(x => new OrderOperationLogChangeItem { @@ -70,7 +76,7 @@ public class OrderOperationLogWriter : IScopedDependency After = x.After }).ToList()), Remark = string.IsNullOrWhiteSpace(remark) ? null : remark.Trim(), - CreateUserId = operatorId, + CreateUserId = resolvedId, CreateTime = DateTime.Now }; @@ -86,6 +92,28 @@ public class OrderOperationLogWriter : IScopedDependency } } + /// + /// 无字段 diff 时写入叙事日志(打印、下游预报成功等)。 + /// + public Task TryWriteSimpleAsync( + Order order, + string action, + string? remark, + int? operatorId = null, + string? operatorName = null, + string operatorType = "staff", + string source = "pc") + => TryWriteAsync( + order, + action, + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase), + remark, + operatorId, + operatorName, + operatorType, + source); + public async Task ListByOrderIdAsync(int orderId) { var rows = await _repository.GetListAsync(x => x.OrderId == orderId); @@ -97,6 +125,30 @@ public class OrderOperationLogWriter : IScopedDependency return new OrderOperationLogListOutput { Items = items }; } + private async Task<(int OperatorId, string? OperatorName)> ResolveOperatorAsync( + int? operatorId, + string? operatorName) + { + if (operatorId is > 0) + { + var name = string.IsNullOrWhiteSpace(operatorName) + ? await _userDisplayNameResolver.ResolveAsync(operatorId.Value) + : operatorName.Trim(); + return (operatorId.Value, name); + } + + if (_currentUser.IsAuthenticated) + { + var id = _currentUser.GetId(); + var name = string.IsNullOrWhiteSpace(operatorName) + ? await _userDisplayNameResolver.ResolveAsync(id) + : operatorName.Trim(); + return (id, name); + } + + return (0, string.IsNullOrWhiteSpace(operatorName) ? null : operatorName.Trim()); + } + private static OrderOperationLogOutput MapOutput(OrderOperationLog row) { var changes = new List(); @@ -113,6 +165,14 @@ public class OrderOperationLogWriter : IScopedDependency } } + var views = changes + .Select(x => new OrderOperationLogSnapshot.OrderOperationLogChangeView( + x.Path, + x.Label, + x.Before, + x.After)) + .ToList(); + return new OrderOperationLogOutput { Id = row.Id, @@ -126,6 +186,7 @@ public class OrderOperationLogWriter : IScopedDependency Source = row.Source, Changes = changes, Remark = row.Remark, + Summary = OrderOperationLogSnapshot.BuildSummary(views, row.Remark), CreateTime = row.CreateTime }; } diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 9049e59..0a98566 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Fake.Application; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; @@ -323,7 +324,7 @@ public partial class OrderService "规则"); if (!string.Equals(order.SalesProductRulesVersion, resolved, StringComparison.Ordinal)) { - remarkParts.Add($"rules:{order.SalesProductRulesVersion ?? "-"}→{resolved}"); + remarkParts.Add($"规则版本 {order.SalesProductRulesVersion ?? "-"}→{resolved}"); order.SalesProductRulesVersion = resolved; } } @@ -337,7 +338,7 @@ public partial class OrderService "价格"); if (!string.Equals(order.SalesProductPriceVersion, resolved, StringComparison.Ordinal)) { - remarkParts.Add($"price:{order.SalesProductPriceVersion ?? "-"}→{resolved}"); + remarkParts.Add($"价格版本 {order.SalesProductPriceVersion ?? "-"}→{resolved}"); order.SalesProductPriceVersion = resolved; } } @@ -354,11 +355,13 @@ public partial class OrderService { EnsureReceivableMutable(order); await RecalculateReceivableCoreAsync(order); + remarkParts.Add("并重算应收"); } if (input.RecalculatePayable) { await RecalculatePayableCoreAsync(order); + remarkParts.Add("并重算应付"); } order = await GetOrderOrThrowAsync(order.Id); @@ -368,7 +371,7 @@ public partial class OrderService OrderOperationActionCodes.BindSnapshotVersions, beforeSnap, afterSnap, - remarkParts.Count == 0 ? null : string.Join(';', remarkParts)); + remarkParts.Count == 0 ? null : string.Join(';', remarkParts)); return (await MapOrderOutputsAsync([order])).First(); } @@ -785,6 +788,7 @@ public partial class OrderService { var order = await GetOrderOrThrowAsync(input.Id); EnsureReceivableMutable(order); + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); if (string.IsNullOrWhiteSpace(order.ProductCode)) { @@ -803,6 +807,15 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.UpdateAdditionalServices, + beforeSnap, + afterSnap); + return (await MapOrderOutputsAsync([order])).First(); } @@ -892,6 +905,8 @@ public partial class OrderService throw new BusinessException("计费阶段无效"); } + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + var fee = new OrderFee { Type = type, @@ -912,6 +927,17 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + var typeName = type == OrderFeeTypeCodes.Receivable ? "应收" : "应付"; + var amountText = input.Amount.ToString("0.####", CultureInfo.InvariantCulture); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.AddFee, + beforeSnap, + afterSnap, + $"新增{typeName}「{costItem}」{currency} {amountText}"); + return (await MapOrderOutputsAsync([order])).First(); } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 8dc9c39..fbd528a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2623,6 +2623,8 @@ public partial class OrderService return; } + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); + int? couponInstanceId = order.CouponInstanceId; if (!string.IsNullOrWhiteSpace(payloadJson)) { @@ -2765,6 +2767,18 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.PreDeclare, + beforeSnap, + afterSnap, + "预报计费/排货完成", + operatorUserId > 0 ? operatorUserId : null, + operatorName: null, + operatorType: "staff", + source: "job"); + var customer = order.CustomerId.HasValue ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 35a155d..5e90860 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Fake.Application; using Fake.Domain.Exceptions; using Fake.ObjectStorage; @@ -147,6 +148,7 @@ public partial class OrderService : ApplicationService throw new BusinessException("订单未关联客户,无法支付"); } + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order); var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); var payAmount = order.PayableAmount ?? fees .Where(x => x.Type == OrderFeeTypeCodes.Receivable) @@ -189,6 +191,16 @@ public partial class OrderService : ApplicationService order.IsPaid = 1; order.OrderStatus = OrderStatusCodes.PendingShip; await UpdateOrderAsync(order); + + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order); + var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture); + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.Pay, + beforeSnap, + afterSnap, + $"支付金额 {amountText}"); } /// @@ -346,6 +358,10 @@ public partial class OrderService : ApplicationService var existingUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id); if (!string.IsNullOrWhiteSpace(existingUrl)) { + await OperationLogWriter.TryWriteSimpleAsync( + order, + OrderOperationActionCodes.Print, + "打印面单(使用已有文件)"); return new OrderPrintOutput { PrintUrl = existingUrl!, @@ -365,6 +381,10 @@ public partial class OrderService : ApplicationService { // 兼容回落:仍无模板时给 HTML stub,避免彻底不可打 var fallbackUrl = await CreateOrderPrintFileAsync(mapped); + await OperationLogWriter.TryWriteSimpleAsync( + order, + OrderOperationActionCodes.Print, + "打印面单(回落 HTML)"); return new OrderPrintOutput { PrintUrl = fallbackUrl, @@ -373,6 +393,10 @@ public partial class OrderService : ApplicationService }; } + await OperationLogWriter.TryWriteSimpleAsync( + order, + OrderOperationActionCodes.Print, + "打印面单"); return new OrderPrintOutput { PrintUrl = result.LabelUrl!, @@ -420,6 +444,21 @@ public partial class OrderService : ApplicationService PrintUrl = url, LabelGenerateStatus = LabelGenerateStatusCodes.Ready }); + try + { + var existingOrder = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (existingOrder != null) + { + await OperationLogWriter.TryWriteSimpleAsync( + existingOrder, + OrderOperationActionCodes.Print, + "批量打印(使用已有文件)"); + } + } + catch + { + // 日志失败不影响打印 + } } else { diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs index 17d6f8b..d6c482b 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -8,6 +8,12 @@ public static class OrderOperationActionCodes public const string RecalcPayable = "recalcPayable"; public const string Reallocate = "reallocate"; public const string BindSnapshotVersions = "bindSnapshotVersions"; + public const string UpdateAdditionalServices = "updateAdditionalServices"; + public const string AddFee = "addFee"; + public const string Pay = "pay"; + public const string PreDeclare = "preDeclare"; + public const string DownstreamForward = "downstreamForward"; + public const string Print = "print"; public static string GetActionName(string action) => action switch { @@ -17,6 +23,12 @@ public static class OrderOperationActionCodes RecalcPayable => "重算应付", Reallocate => "重新排货", BindSnapshotVersions => "切换价格/规则版本", + UpdateAdditionalServices => "更新附加服务", + AddFee => "添加费用", + Pay => "支付", + PreDeclare => "重新预报", + DownstreamForward => "预报下游", + Print => "打印面单", _ => action }; }