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 }; }