All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
开放 DownstreamServiceNo 更新,补充包裹计费相关操作码与字段快照,便于运单详情编辑留痕。 Co-authored-by: Cursor <cursoragent@cursor.com>
373 lines
14 KiB
C#
373 lines
14 KiB
C#
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";
|
||
}
|