feat: 运单操作日志改为列表摘要,补齐费用/支付/预报/打印埋点
All checks were successful
Build and Deploy / deploy (push) Successful in 1m3s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-10 11:46:33 +08:00
parent dcd985bca4
commit 64737284f3
9 changed files with 289 additions and 17 deletions

View File

@ -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 ''操作人展示名'',

View File

@ -35,6 +35,11 @@ public class OrderOperationLogOutput
public string? Remark { get; set; }
/// <summary>
/// 列表「内容」列:由备注 + 字段变更摘要拼装。
/// </summary>
public string Summary { get; set; } = string.Empty;
public DateTime CreateTime { get; set; }
}

View File

@ -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<string> { $"供应商 {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,

View File

@ -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;
/// </summary>
public static class OrderOperationLogSnapshot
{
public const int SummaryMaxChangeItems = 5;
private static readonly Dictionary<string, string> 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;
}
/// <summary>
/// 拼装列表「内容」列:备注优先,再跟最多 N 条字段变更。
/// </summary>
public static string BuildSummary(
IReadOnlyList<OrderOperationLogChangeView> changes,
string? remark,
int maxItems = SummaryMaxChangeItems)
{
var parts = new List<string>();
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<List<string>>(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))

View File

@ -33,23 +33,29 @@ public class OrderOperationLogWriter : IScopedDependency
_logger = logger;
}
public async Task TryWriteAsync(
public Task TryWriteAsync(
Order order,
string action,
IReadOnlyDictionary<string, string?> before,
IReadOnlyDictionary<string, string?> after,
string? remark = null)
=> TryWriteAsync(order, action, before, after, remark, operatorId: null, operatorName: null);
public async Task TryWriteAsync(
Order order,
string action,
IReadOnlyDictionary<string, string?> before,
IReadOnlyDictionary<string, string?> 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<int>();
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
}
}
/// <summary>
/// 无字段 diff 时写入叙事日志(打印、下游预报成功等)。
/// </summary>
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<string, string?>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
remark,
operatorId,
operatorName,
operatorType,
source);
public async Task<OrderOperationLogListOutput> 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<int>();
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<OrderOperationLogChangeItem>();
@ -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
};
}

View File

@ -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();
}

View File

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

View File

@ -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}");
}
/// <summary>
@ -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
{

View File

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