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