admin/src/Oms.Application/Services/Orders/OrderOperationLogSnapshot.cs
zzh 8df0f37165
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
feat: 订单编辑支持服务商单号并完善操作日志快照
开放 DownstreamServiceNo 更新,补充包裹计费相关操作码与字段快照,便于运单详情编辑留痕。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 16:27:49 +08:00

373 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Globalization;
using Newtonsoft.Json;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 运单操作日志白名单快照与字段 label。
/// </summary>
public static class OrderOperationLogSnapshot
{
public const int SummaryMaxChangeItems = 5;
private static readonly Dictionary<string, string> Labels = new(StringComparer.OrdinalIgnoreCase)
{
["orderStatus"] = "订单状态",
["clientNo"] = "客户单号",
["trackNo"] = "跟踪号",
["channelCode"] = "服务渠道",
["productCode"] = "销售产品",
["warehouseId"] = "仓库",
["cargoAttribute"] = "货物属性",
["isCod"] = "是否COD",
["codCurrency"] = "COD币种",
["codSum"] = "COD金额",
["declaredCurrency"] = "申报币种",
["declaredSum"] = "申报金额",
["quantity"] = "包裹数量",
["receivableSubtotal"] = "应收小计",
["payableAmount"] = "应付金额",
["downstreamServiceNo"] = "服务商单号",
["allocationScheme"] = "排货方案",
["salesProductRulesVersion"] = "规则版本",
["salesProductPriceVersion"] = "价格版本",
["isPaid"] = "是否已支付",
["additionalServices"] = "附加服务",
["sender.name"] = "发件人姓名",
["sender.company"] = "发件公司",
["sender.phone"] = "发件人电话",
["sender.telePhone"] = "发件人固话",
["sender.email"] = "发件人邮箱",
["sender.country"] = "发件国家",
["sender.province"] = "发件省/州",
["sender.city"] = "发件城市",
["sender.zipCode"] = "发件邮编",
["sender.address1"] = "发件地址1",
["sender.address2"] = "发件地址2",
["sender.address3"] = "发件地址3",
["sender.houseNumber"] = "发件门牌号",
["receiver.name"] = "收件人姓名",
["receiver.company"] = "收件公司",
["receiver.phone"] = "收件人电话",
["receiver.telePhone"] = "收件人固话",
["receiver.email"] = "收件人邮箱",
["receiver.country"] = "收件国家",
["receiver.province"] = "收件省/州",
["receiver.city"] = "收件城市",
["receiver.zipCode"] = "收件邮编",
["receiver.streetNumber"] = "收件街道号",
["receiver.address1"] = "收件地址1",
["receiver.address2"] = "收件地址2",
["receiver.address3"] = "收件地址3",
["receiver.taxType"] = "收件税号类型",
["receiver.taxNo"] = "收件税号",
["receiver.certificateType"] = "收件证件类型",
["receiver.certificateNo"] = "收件证件号",
["receiver.eori"] = "收件EORI",
["receiver.vat"] = "收件VAT",
["fee.receivableTotal"] = "应收合计",
["fee.payableTotal"] = "应付合计",
};
public static Dictionary<string, string?> CaptureOrder(Order order)
{
return new Dictionary<string, string?>(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),
["salesProductRulesVersion"] = Norm(order.SalesProductRulesVersion),
["salesProductPriceVersion"] = Norm(order.SalesProductPriceVersion),
["isPaid"] = FormatByte(order.IsPaid),
["additionalServices"] = FormatAdditionalServices(order.AdditionalServices),
};
}
public static Dictionary<string, string?> CaptureSender(OrderSender? sender)
{
if (sender == null)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["sender.name"] = Norm(sender.SenderName),
["sender.company"] = Norm(sender.Company),
["sender.phone"] = Norm(sender.Phone),
["sender.telePhone"] = Norm(sender.TelePhone),
["sender.email"] = Norm(sender.Email),
["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),
["sender.address3"] = Norm(sender.StreetAddress3),
["sender.houseNumber"] = Norm(sender.HouseNumber),
};
}
public static Dictionary<string, string?> CaptureReceiver(OrderRecipient? recipient)
{
if (recipient == null)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["receiver.name"] = Norm(recipient.RecipientsName),
["receiver.company"] = Norm(recipient.CompanyName),
["receiver.phone"] = Norm(recipient.Phone),
["receiver.telePhone"] = Norm(recipient.TelePhone),
["receiver.email"] = Norm(recipient.Email),
["receiver.country"] = FormatInt(recipient.Country),
["receiver.province"] = Norm(recipient.Province),
["receiver.city"] = Norm(recipient.City),
["receiver.zipCode"] = Norm(recipient.ZipCode),
["receiver.streetNumber"] = Norm(recipient.StreetNumber),
["receiver.address1"] = Norm(recipient.StreetAddress1),
["receiver.address2"] = Norm(recipient.StreetAddress2),
["receiver.address3"] = Norm(recipient.StreetAddress3),
["receiver.taxType"] = FormatInt(recipient.TaxType),
["receiver.taxNo"] = Norm(recipient.TaxNo),
["receiver.certificateType"] = FormatInt(recipient.CertificateType),
["receiver.certificateNo"] = Norm(recipient.CertificateNo),
["receiver.eori"] = Norm(recipient.Eori),
["receiver.vat"] = Norm(recipient.Vat),
};
}
public static Dictionary<string, string?> CaptureWeights(IEnumerable<OrderWeight> weights)
{
var map = new Dictionary<string, string?>(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<string, string?> CaptureFeeTotals(IEnumerable<OrderFee> 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<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["fee.receivableTotal"] = FormatDecimal(receivable),
["fee.payableTotal"] = FormatDecimal(payable),
};
}
public static Dictionary<string, string?> Merge(params IReadOnlyDictionary<string, string?>[] parts)
{
var map = new Dictionary<string, string?>(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<string, string?> before,
IReadOnlyDictionary<string, string?> 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;
}
/// <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))
{
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";
}