admin/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs
zzh ca95e19186
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
feat: SKU/预报下游申报明细带产品标识编码三字段
库内 SKU 与 createOrderOne items 对齐 productId/nonStdProductId/stdProductId;无值时仍序列化为 null。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 11:19:12 +08:00

596 lines
21 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.Text.Json;
using System.Text.Json.Nodes;
using Fake.Application;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.PlatformStore;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.WarehouseAggregate.Entities;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 将店铺订单转换为小包创建入参。
/// </summary>
public static class PlatformStoreOrderParcelBuilder
{
private const decimal DefaultLengthCm = 10m;
private const decimal DefaultWidthCm = 10m;
private const decimal DefaultHeightCm = 10m;
private const decimal MinActualWeightKg = 0.01m;
public static OrderParcelCreateInput Build(
SalesStoreOrder storeOrder,
IReadOnlyCollection<SalesStoreOrderLine> lines,
IReadOnlyDictionary<int, CSku> skuMap,
WarehouseAddress warehouse,
string productCode,
int warehouseId,
int customerId,
OrderParcelReceiverInput? receiverOverride = null,
string? trackNoOverride = null)
{
var receiver = receiverOverride ?? ParseReceiver(storeOrder);
ValidateReceiver(receiver);
var declarationLines = BuildDeclarationLines(lines, skuMap);
var (length, width, height, actualWeightKg) = ResolveParcelMetrics(declarationLines, lines, skuMap);
return new OrderParcelCreateInput
{
RequestId = $"platform-store-order:{storeOrder.Id}",
ClientNo = storeOrder.PlatformOrderNo,
TrackNo = string.IsNullOrWhiteSpace(trackNoOverride) ? storeOrder.ShippingLabelNo : trackNoOverride.Trim(),
ProductCode = productCode,
WarehouseId = warehouseId,
CustomerId = customerId,
Length = length,
Width = width,
Height = height,
ActualWeight = actualWeightKg,
ChargeableWeight = actualWeightKg,
CargoAttribute = CargoAttributeCodes.General,
DeclaredCurrency = string.IsNullOrWhiteSpace(storeOrder.Currency) ? "USD" : storeOrder.Currency.Trim(),
DeclaredSum = storeOrder.TotalAmount,
Quantity = 1,
Receiver = receiver,
Sender = BuildSender(warehouse),
Items = declarationLines
};
}
/// <summary>
/// 将下单页同款「运单基本信息」覆盖到组装好的小包入参。
/// </summary>
public static void ApplyParcelBasic(
OrderParcelCreateInput parcelInput,
PlatformStoreOrderParcelBasicInput? basic,
string? clientNoOverride = null)
{
if (!string.IsNullOrWhiteSpace(clientNoOverride))
{
parcelInput.ClientNo = clientNoOverride.Trim();
}
else if (!string.IsNullOrWhiteSpace(basic?.ClientNo))
{
parcelInput.ClientNo = basic.ClientNo.Trim();
}
if (basic == null)
{
return;
}
if (!string.IsNullOrWhiteSpace(basic.CargoAttribute))
{
parcelInput.CargoAttribute = basic.CargoAttribute.Trim();
}
if (basic.ActualWeight is > 0)
{
parcelInput.ActualWeight = basic.ActualWeight;
parcelInput.ChargeableWeight = basic.ActualWeight;
}
if (basic.Quantity is > 0)
{
parcelInput.Quantity = basic.Quantity;
}
if (basic.Length is > 0)
{
parcelInput.Length = basic.Length;
}
if (basic.Width is > 0)
{
parcelInput.Width = basic.Width;
}
if (basic.Height is > 0)
{
parcelInput.Height = basic.Height;
}
if (!string.IsNullOrWhiteSpace(basic.ExportCustomsMode))
{
parcelInput.ExportCustomsMode = basic.ExportCustomsMode.Trim();
}
if (basic.ExportQualificationId is > 0)
{
parcelInput.ExportQualificationId = basic.ExportQualificationId;
}
if (!string.IsNullOrWhiteSpace(basic.PlatformCode))
{
parcelInput.PlatformCode = basic.PlatformCode.Trim();
}
if (!string.IsNullOrWhiteSpace(basic.InboundMethod))
{
parcelInput.InboundMethod = basic.InboundMethod.Trim();
}
if (basic.SenderAddressId is > 0)
{
parcelInput.SenderAddressId = basic.SenderAddressId;
}
if (basic.ExpectPickupTime.HasValue && basic.ExpectPickupTime != default)
{
parcelInput.ExpectPickupTime = basic.ExpectPickupTime;
}
if (!string.IsNullOrWhiteSpace(basic.PickupDirection))
{
parcelInput.PickupDirection = basic.PickupDirection.Trim();
}
if (!string.IsNullOrWhiteSpace(basic.CarrierCode))
{
parcelInput.CarrierCode = basic.CarrierCode.Trim();
}
if (!string.IsNullOrWhiteSpace(basic.CarrierService))
{
parcelInput.CarrierService = basic.CarrierService.Trim();
}
}
/// <summary>
/// 按平台商品行组装小包入参(不要求库内 SKU 映射;用于创建物流 / 物流&amp;代发)。
/// </summary>
public static OrderParcelCreateInput BuildFromPlatformLines(
SalesStoreOrder storeOrder,
IReadOnlyCollection<SalesStoreOrderLine> lines,
WarehouseAddress? warehouse,
string productCode,
int? warehouseId,
int customerId,
OrderParcelReceiverInput? receiverOverride = null,
string? trackNoOverride = null)
{
var receiver = receiverOverride ?? ParseReceiver(storeOrder);
ValidateReceiver(receiver);
var declarationLines = BuildDeclarationLinesFromPlatform(lines);
var totalWeightKg = Math.Max(
declarationLines.Sum(x => x.Weight ?? 0m),
MinActualWeightKg);
return new OrderParcelCreateInput
{
RequestId = $"platform-store-order:{storeOrder.Id}",
ClientNo = storeOrder.PlatformOrderNo,
TrackNo = string.IsNullOrWhiteSpace(trackNoOverride) ? storeOrder.ShippingLabelNo : trackNoOverride?.Trim(),
ProductCode = productCode,
WarehouseId = warehouseId,
CustomerId = customerId,
Length = DefaultLengthCm,
Width = DefaultWidthCm,
Height = DefaultHeightCm,
ActualWeight = totalWeightKg,
ChargeableWeight = totalWeightKg,
CargoAttribute = CargoAttributeCodes.General,
DeclaredCurrency = string.IsNullOrWhiteSpace(storeOrder.Currency) ? "USD" : storeOrder.Currency.Trim(),
DeclaredSum = storeOrder.TotalAmount,
Quantity = 1,
Receiver = receiver,
Sender = warehouse != null ? BuildSender(warehouse) : BuildFallbackSender(storeOrder),
Items = declarationLines
};
}
private static List<OrderParcelItemInput> BuildDeclarationLinesFromPlatform(
IReadOnlyCollection<SalesStoreOrderLine> lines)
{
var items = new List<OrderParcelItemInput>();
foreach (var line in lines)
{
var cnName = string.IsNullOrWhiteSpace(line.ProductName)
? (string.IsNullOrWhiteSpace(line.PlatformSku) ? "Platform Item" : line.PlatformSku.Trim())
: line.ProductName.Trim();
var sku = string.IsNullOrWhiteSpace(line.PlatformSku)
? (cnName.Length > 64 ? cnName[..64] : cnName)
: line.PlatformSku.Trim();
var unitPrice = line.UnitPrice is > 0 ? line.UnitPrice.Value : 1m;
var qty = Math.Max(1, line.Quantity);
items.Add(new OrderParcelItemInput
{
CnName = cnName,
EnName = cnName,
Sku = sku,
Unit = "PCS",
UnitPrice = unitPrice,
UnitWeight = MinActualWeightKg,
Quantity = qty,
Amount = unitPrice * qty,
Weight = MinActualWeightKg * qty
});
}
if (items.Count == 0)
{
throw new BusinessException("没有可申报的商品行");
}
return items;
}
private static OrderParcelSenderInput BuildFallbackSender(SalesStoreOrder storeOrder)
{
return new OrderParcelSenderInput
{
SenderName = string.IsNullOrWhiteSpace(storeOrder.PlatformOrderNo)
? "Platform Seller"
: $"Store#{storeOrder.StoreId}",
StreetAddress1 = "TBD",
CountryCode = storeOrder.OrderCountry
};
}
private static List<OrderParcelItemInput> BuildDeclarationLines(
IReadOnlyCollection<SalesStoreOrderLine> lines,
IReadOnlyDictionary<int, CSku> skuMap)
{
var items = new List<OrderParcelItemInput>();
foreach (var line in lines)
{
if (!line.MappedSkuId.HasValue || !skuMap.TryGetValue(line.MappedSkuId.Value, out var sku))
{
throw new BusinessException($"申报行「{line.PlatformSku}」缺少库内 SKU 映射");
}
var unitWeightKg = sku.WeightG is > 0 ? Math.Max(sku.WeightG.Value / 1000m, MinActualWeightKg) : MinActualWeightKg;
var unitPrice = line.UnitPrice is > 0 ? line.UnitPrice.Value : 1m;
var cnName = string.IsNullOrWhiteSpace(line.ProductName) ? sku.SkuName : line.ProductName.Trim();
var enName = string.IsNullOrWhiteSpace(sku.SkuNameEn)
? (string.IsNullOrWhiteSpace(sku.SkuName) ? cnName : sku.SkuName)
: sku.SkuNameEn.Trim();
items.Add(new OrderParcelItemInput
{
CnName = cnName,
EnName = enName,
Sku = sku.SkuCode,
Unit = "PCS",
UnitPrice = unitPrice,
UnitWeight = unitWeightKg,
Quantity = line.Quantity,
Amount = unitPrice * line.Quantity,
Weight = unitWeightKg * line.Quantity,
ExHsCode = sku.ExHsCode,
ImHsCode = sku.ImHsCode,
DeclareElement = sku.DeclareElement,
ProductId = sku.ProductId,
NonStdProductId = sku.NonStdProductId,
StdProductId = sku.StdProductId,
});
}
if (items.Count == 0)
{
throw new BusinessException("没有可申报的商品行");
}
return items;
}
private static (decimal Length, decimal Width, decimal Height, decimal ActualWeightKg) ResolveParcelMetrics(
IReadOnlyCollection<OrderParcelItemInput> items,
IReadOnlyCollection<SalesStoreOrderLine> lines,
IReadOnlyDictionary<int, CSku> skuMap)
{
decimal maxLength = DefaultLengthCm;
decimal maxWidth = DefaultWidthCm;
decimal maxHeight = DefaultHeightCm;
decimal totalWeightKg = 0m;
foreach (var line in lines.Where(x => x.MappedSkuId.HasValue))
{
if (!skuMap.TryGetValue(line.MappedSkuId!.Value, out var sku))
{
continue;
}
if (sku.LengthCm is > 0)
{
maxLength = Math.Max(maxLength, sku.LengthCm.Value);
}
if (sku.WidthCm is > 0)
{
maxWidth = Math.Max(maxWidth, sku.WidthCm.Value);
}
if (sku.HeightCm is > 0)
{
maxHeight = Math.Max(maxHeight, sku.HeightCm.Value);
}
var unitWeightKg = sku.WeightG is > 0 ? sku.WeightG.Value / 1000m : MinActualWeightKg;
totalWeightKg += unitWeightKg * line.Quantity;
}
if (totalWeightKg <= 0)
{
totalWeightKg = Math.Max(items.Sum(x => x.Weight ?? 0m), MinActualWeightKg);
}
return (maxLength, maxWidth, maxHeight, Math.Max(totalWeightKg, MinActualWeightKg));
}
private static OrderParcelSenderInput BuildSender(WarehouseAddress warehouse)
{
var senderName = string.IsNullOrWhiteSpace(warehouse.Recipient) ? warehouse.WarehouseName : warehouse.Recipient.Trim();
return new OrderParcelSenderInput
{
SenderName = senderName,
Phone = warehouse.ContactPhone,
StreetAddress1 = string.IsNullOrWhiteSpace(warehouse.ShippingAddress) ? warehouse.WarehouseName : warehouse.ShippingAddress.Trim()
};
}
private static OrderParcelReceiverInput? ParseReceiver(SalesStoreOrder storeOrder)
{
if (!string.IsNullOrWhiteSpace(storeOrder.RecipientName)
&& !string.IsNullOrWhiteSpace(storeOrder.RecipientCountry ?? storeOrder.OrderCountry)
&& !string.IsNullOrWhiteSpace(storeOrder.RecipientZipCode)
&& !string.IsNullOrWhiteSpace(storeOrder.RecipientAddress1))
{
return new OrderParcelReceiverInput
{
RecipientsName = storeOrder.RecipientName.Trim(),
CompanyName = storeOrder.RecipientCompany,
Phone = storeOrder.RecipientPhone,
TelePhone = storeOrder.RecipientTelePhone,
Email = storeOrder.RecipientEmail,
CountryCode = (storeOrder.RecipientCountry ?? storeOrder.OrderCountry)!.Trim(),
Province = storeOrder.RecipientProvince,
City = storeOrder.RecipientCity,
StreetAddress1 = storeOrder.RecipientAddress1.Trim(),
StreetAddress2 = storeOrder.RecipientAddress2,
ZipCode = storeOrder.RecipientZipCode.Trim()
};
}
if (!string.IsNullOrWhiteSpace(storeOrder.PlatformExtraJson))
{
try
{
var node = JsonNode.Parse(storeOrder.PlatformExtraJson);
if (node != null)
{
var parsed = ParseReceiverNode(node);
if (parsed != null)
{
return parsed;
}
}
}
catch (JsonException)
{
// 忽略 JSON 解析错误,走兜底
}
}
if (string.IsNullOrWhiteSpace(storeOrder.OrderCountry))
{
return null;
}
return new OrderParcelReceiverInput
{
RecipientsName = "Platform Recipient",
CountryCode = storeOrder.OrderCountry.Trim(),
ZipCode = "00000",
StreetAddress1 = "TBD"
};
}
private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node)
{
var candidates = new List<JsonNode?> { node };
foreach (var key in new[]
{
"recipient_address", "recipientAddress", "recipient", "receiver", "shippingAddress", "address"
})
{
if (node[key] != null)
{
candidates.Add(node[key]);
}
}
foreach (var candidate in candidates)
{
if (candidate == null)
{
continue;
}
var (province, city, countryFromDistrict) = ParseTiktokDistrictInfo(candidate);
var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName", "first_name");
var country = ReadText(candidate, "countryCode", "country", "region_code", "region", "orderCountry")
?? countryFromDistrict
?? ReadText(node, "orderCountry");
var zip = ReadText(candidate, "zipCode", "zip", "postal_code", "postalCode", "postcode");
var address = ReadText(
candidate,
"streetAddress1",
"address_line1",
"address_detail",
"address",
"address1",
"full_address",
"fullAddress",
"detailAddress");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(country) || string.IsNullOrWhiteSpace(zip) || string.IsNullOrWhiteSpace(address))
{
continue;
}
return new OrderParcelReceiverInput
{
RecipientsName = name,
CompanyName = ReadText(candidate, "companyName", "company"),
Phone = ReadText(candidate, "phone", "mobile", "phone_number", "phoneNumber"),
TelePhone = ReadText(candidate, "telePhone", "tel"),
Email = ReadText(candidate, "email") ?? ReadText(node, "buyer_email"),
CountryCode = country,
Province = ReadText(candidate, "province", "state", "regionState") ?? province,
City = ReadText(candidate, "city") ?? city,
StreetAddress1 = address,
StreetAddress2 = ReadText(candidate, "streetAddress2", "address_line2", "address2"),
ZipCode = zip
};
}
return null;
}
/// <summary>
/// 解析 TikTok recipient_address.district_info:L0 国家、L1 州省、L3 城市。
/// </summary>
private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrictInfo(JsonNode candidate)
{
var districts = candidate["district_info"]?.AsArray();
if (districts == null || districts.Count == 0)
{
return (null, null, null);
}
string? province = null;
string? city = null;
string? country = null;
foreach (var district in districts)
{
if (district == null) continue;
var level = district["address_level"]?.ToString();
var name = district["address_name"]?.ToString()?.Trim();
var iso = district["iso_code"]?.ToString()?.Trim();
switch (level)
{
case "L0":
country = !string.IsNullOrWhiteSpace(iso) ? iso : name;
break;
case "L1":
province = name;
break;
case "L3":
city = name;
break;
case "L2" when string.IsNullOrWhiteSpace(city):
city = name;
break;
}
}
return (province, city, country);
}
private static string? ReadText(JsonNode node, params string[] keys)
{
foreach (var key in keys)
{
var value = node[key]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static void ValidateReceiver(OrderParcelReceiverInput? receiver)
{
if (receiver == null
|| string.IsNullOrWhiteSpace(receiver.RecipientsName)
|| string.IsNullOrWhiteSpace(receiver.CountryCode)
|| string.IsNullOrWhiteSpace(receiver.ZipCode)
|| string.IsNullOrWhiteSpace(receiver.StreetAddress1))
{
throw new BusinessException("收件人信息不完整,请同步平台订单或在匹配时补充收件人");
}
}
/// <summary>
/// 从 PlatformExtraJson / ExtraJson 解析收件人并写入结构化字段(已有值不覆盖)。
/// </summary>
public static void TryFillRecipientFields(SalesStoreOrder order, string? extraJson = null)
{
if (!string.IsNullOrWhiteSpace(order.RecipientName)
&& !string.IsNullOrWhiteSpace(order.RecipientAddress1)
&& !string.IsNullOrWhiteSpace(order.RecipientZipCode)
&& !string.IsNullOrWhiteSpace(order.RecipientCountry ?? order.OrderCountry))
{
return;
}
var json = !string.IsNullOrWhiteSpace(extraJson) ? extraJson : order.PlatformExtraJson;
if (string.IsNullOrWhiteSpace(json))
{
return;
}
try
{
var node = JsonNode.Parse(json);
if (node == null)
{
return;
}
var parsed = ParseReceiverNode(node);
if (parsed == null)
{
return;
}
order.RecipientName ??= parsed.RecipientsName;
order.RecipientCompany ??= parsed.CompanyName;
order.RecipientPhone ??= parsed.Phone;
order.RecipientTelePhone ??= parsed.TelePhone;
order.RecipientEmail ??= parsed.Email;
order.RecipientCountry ??= parsed.CountryCode;
order.RecipientProvince ??= parsed.Province;
order.RecipientCity ??= parsed.City;
order.RecipientZipCode ??= parsed.ZipCode;
order.RecipientAddress1 ??= parsed.StreetAddress1;
order.RecipientAddress2 ??= parsed.StreetAddress2;
if (string.IsNullOrWhiteSpace(order.OrderCountry) && !string.IsNullOrWhiteSpace(parsed.CountryCode))
{
order.OrderCountry = parsed.CountryCode;
}
}
catch (JsonException)
{
// ignore
}
}
}