feat: 店铺订单拉单同步、RTS 与重新获取面单
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c2c03f63e3
commit
9a48ec1a63
@ -335,9 +335,63 @@ public class PlatformStoreOrderToggleSuspendInput
|
||||
public bool IsSuspended { get; set; }
|
||||
}
|
||||
|
||||
public class PlatformStoreOrderPullInput
|
||||
{
|
||||
[Required]
|
||||
public List<int> StoreIds { get; set; } = [];
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
public List<string> OrderStatuses { get; set; } = [];
|
||||
|
||||
public DateTime? TimeStart { get; set; }
|
||||
|
||||
public DateTime? TimeEnd { get; set; }
|
||||
}
|
||||
|
||||
public class PlatformStoreOrderBatchActionItemOutput
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
|
||||
public int? StoreId { get; set; }
|
||||
|
||||
public string? StoreNickname { get; set; }
|
||||
|
||||
public string? PlatformOrderNo { get; set; }
|
||||
|
||||
public bool Success { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public int AffectedCount { get; set; }
|
||||
|
||||
public string? FulfillStatus { get; set; }
|
||||
|
||||
public string? ShippingLabelNo { get; set; }
|
||||
|
||||
public string? ShippingLabelUrl { get; set; }
|
||||
}
|
||||
|
||||
public class PlatformStoreOrderBatchActionOutput
|
||||
{
|
||||
public bool Success { get; set; } = true;
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
public int FailCount { get; set; }
|
||||
|
||||
public List<PlatformStoreOrderBatchActionItemOutput> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
public class PlatformStoreOrderActionOutput
|
||||
{
|
||||
public bool Success { get; set; } = true;
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public string? ShippingLabelNo { get; set; }
|
||||
|
||||
public string? ShippingLabelUrl { get; set; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,940 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Integrations.PlatformShop;
|
||||
|
||||
public interface IPlatformShopFulfillmentClient
|
||||
{
|
||||
Task<PlatformShopPullOrdersResult> PullOrdersAsync(
|
||||
SalesStore store,
|
||||
PlatformShopPullOrdersRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<PlatformShopOrderStatusResult> FetchOrderStatusAsync(
|
||||
SalesStore store,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<PlatformShopReadyToShipResult> ReadyToShipAsync(
|
||||
SalesStore store,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<PlatformShopLabelResult> FetchShippingLabelAsync(
|
||||
SalesStore store,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformShopPullOrdersRequest
|
||||
{
|
||||
public List<string> OrderStatuses { get; set; } = [];
|
||||
|
||||
public DateTime? TimeStart { get; set; }
|
||||
|
||||
public DateTime? TimeEnd { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformShopPullOrdersResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public List<PlatformShopPulledOrder> Orders { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PlatformShopPulledOrder
|
||||
{
|
||||
public string PlatformOrderNo { get; set; } = string.Empty;
|
||||
|
||||
public string? OrderCountry { get; set; }
|
||||
|
||||
public int ItemCount { get; set; }
|
||||
|
||||
public decimal? TotalAmount { get; set; }
|
||||
|
||||
public string? Currency { get; set; }
|
||||
|
||||
public DateTime? PlatformOrderTime { get; set; }
|
||||
|
||||
public string? PlatformStatus { get; set; }
|
||||
|
||||
public string? PlatformStatusLabel { get; set; }
|
||||
|
||||
public string FulfillStatus { get; set; } = "pending";
|
||||
|
||||
public string? ShippingLabelNo { get; set; }
|
||||
|
||||
public string? ShippingLabelUrl { get; set; }
|
||||
|
||||
public string? ExtraJson { get; set; }
|
||||
|
||||
public List<PlatformShopPulledOrderLine> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PlatformShopPulledOrderLine
|
||||
{
|
||||
public string? PlatformLineId { get; set; }
|
||||
|
||||
public string? PlatformSku { get; set; }
|
||||
|
||||
public string? ProductName { get; set; }
|
||||
|
||||
public int Quantity { get; set; }
|
||||
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
public string? Currency { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformShopOrderStatusResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public string? PlatformStatus { get; set; }
|
||||
|
||||
public string? PlatformStatusLabel { get; set; }
|
||||
|
||||
public string? FulfillStatus { get; set; }
|
||||
|
||||
public string? ShippingLabelNo { get; set; }
|
||||
|
||||
public string? ShippingLabelUrl { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformShopReadyToShipResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public string? PlatformStatus { get; set; }
|
||||
|
||||
public string? PlatformStatusLabel { get; set; }
|
||||
|
||||
public string? FulfillStatus { get; set; }
|
||||
|
||||
public string? ShippingLabelNo { get; set; }
|
||||
|
||||
public string? ShippingLabelUrl { get; set; }
|
||||
|
||||
public bool LabelPending { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformShopLabelResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public string? ShippingLabelNo { get; set; }
|
||||
|
||||
public string? ShippingLabelUrl { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PlatformShopFulfillmentClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IPlatformShopConfigProvider configProvider,
|
||||
ILogger<PlatformShopFulfillmentClient> logger) : IPlatformShopFulfillmentClient
|
||||
{
|
||||
private const string LabelPendingText = "系统获取中";
|
||||
|
||||
public async Task<PlatformShopPullOrdersResult> PullOrdersAsync(
|
||||
SalesStore store,
|
||||
PlatformShopPullOrdersRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var options = await configProvider.GetAsync(ct);
|
||||
return store.PlatformCode.ToLowerInvariant() switch
|
||||
{
|
||||
"shopee" => await PullShopeeAsync(store, options, request, ct),
|
||||
"lazada" => await PullLazadaAsync(store, options, request, ct),
|
||||
"tiktok" => await PullTiktokAsync(store, options, request, ct),
|
||||
"temu" => await PullTemuAsync(store, options, request, ct),
|
||||
_ => new PlatformShopPullOrdersResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持拉单" }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlatformShopOrderStatusResult> FetchOrderStatusAsync(
|
||||
SalesStore store,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var options = await configProvider.GetAsync(ct);
|
||||
return store.PlatformCode.ToLowerInvariant() switch
|
||||
{
|
||||
"shopee" => await SyncShopeeAsync(store, options, platformOrderNo, ct),
|
||||
"lazada" => await SyncLazadaAsync(store, options, platformOrderNo, ct),
|
||||
"tiktok" => await SyncTiktokAsync(store, options, platformOrderNo, ct),
|
||||
"temu" => await SyncTemuAsync(store, options, platformOrderNo, ct),
|
||||
_ => new PlatformShopOrderStatusResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持状态同步" }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlatformShopReadyToShipResult> ReadyToShipAsync(
|
||||
SalesStore store,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var options = await configProvider.GetAsync(ct);
|
||||
return store.PlatformCode.ToLowerInvariant() switch
|
||||
{
|
||||
"shopee" => await RtsShopeeAsync(store, options, platformOrderNo, ct),
|
||||
"lazada" => await RtsLazadaAsync(store, options, platformOrderNo, ct),
|
||||
"tiktok" => await RtsTiktokAsync(store, options, platformOrderNo, ct),
|
||||
"temu" => new PlatformShopReadyToShipResult { Success = false, Message = "Temu 暂不支持 RTS" },
|
||||
_ => new PlatformShopReadyToShipResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持 RTS" }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PlatformShopLabelResult> FetchShippingLabelAsync(
|
||||
SalesStore store,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var options = await configProvider.GetAsync(ct);
|
||||
return store.PlatformCode.ToLowerInvariant() switch
|
||||
{
|
||||
"shopee" => await LabelShopeeAsync(store, options, platformOrderNo, ct),
|
||||
"lazada" => await LabelLazadaAsync(store, options, platformOrderNo, ct),
|
||||
"tiktok" => await LabelTiktokAsync(store, options, platformOrderNo, ct),
|
||||
"temu" => await LabelTemuAsync(store, options, platformOrderNo, ct),
|
||||
_ => new PlatformShopLabelResult { Success = false, Message = $"平台 {store.PlatformCode} 暂不支持面单拉取" }
|
||||
};
|
||||
}
|
||||
|
||||
#region Shopee
|
||||
|
||||
private async Task<PlatformShopPullOrdersResult> PullShopeeAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
PlatformShopPullOrdersRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureShopee(store, options);
|
||||
var shopee = options.Shopee;
|
||||
var shopId = ResolveShopId(store);
|
||||
var token = store.AccessToken!;
|
||||
var timeFrom = request.TimeStart ?? DateTime.Now.AddDays(-7);
|
||||
var timeTo = request.TimeEnd ?? DateTime.Now;
|
||||
var orderSns = new List<string>();
|
||||
var cursor = string.Empty;
|
||||
|
||||
do
|
||||
{
|
||||
var path = "/api/v2/order/get_order_list";
|
||||
var ts = PlatformShopCrypto.UnixSeconds();
|
||||
var sign = PlatformShopCrypto.HmacSha256Hex(
|
||||
$"{shopee.PartnerId}{path}{ts}{token}{shopId}",
|
||||
shopee.PartnerKey);
|
||||
var url =
|
||||
$"{shopee.HostAuthUrl}{path}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(token)}&sign={sign}×tamp={ts}" +
|
||||
$"&time_range_field=create_time&time_from={ToUnix(timeFrom)}&time_to={ToUnix(timeTo)}&page_size=50" +
|
||||
(string.IsNullOrEmpty(cursor) ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
|
||||
|
||||
using var doc = await GetJsonAsync(url, ct);
|
||||
var list = doc.RootElement.GetProperty("response").GetProperty("order_list");
|
||||
foreach (var item in list.EnumerateArray())
|
||||
{
|
||||
var sn = GetString(item, "order_sn");
|
||||
if (!string.IsNullOrWhiteSpace(sn)) orderSns.Add(sn);
|
||||
}
|
||||
|
||||
cursor = doc.RootElement.GetProperty("response").TryGetProperty("next_cursor", out var nc)
|
||||
? nc.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
} while (!string.IsNullOrEmpty(cursor));
|
||||
|
||||
var orders = new List<PlatformShopPulledOrder>();
|
||||
foreach (var sn in orderSns.Distinct())
|
||||
{
|
||||
var detail = await SyncShopeeAsync(store, options, sn, ct);
|
||||
if (!detail.Success) continue;
|
||||
orders.Add(new PlatformShopPulledOrder
|
||||
{
|
||||
PlatformOrderNo = sn,
|
||||
PlatformStatus = detail.PlatformStatus,
|
||||
PlatformStatusLabel = detail.PlatformStatusLabel,
|
||||
FulfillStatus = detail.FulfillStatus ?? MapShopeeFulfill(detail.PlatformStatus),
|
||||
ShippingLabelNo = detail.ShippingLabelNo,
|
||||
ShippingLabelUrl = detail.ShippingLabelUrl,
|
||||
PlatformOrderTime = timeFrom
|
||||
});
|
||||
}
|
||||
|
||||
return new PlatformShopPullOrdersResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Shopee 拉单完成,处理 {orders.Count} 单",
|
||||
Orders = orders
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopOrderStatusResult> SyncShopeeAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureShopee(store, options);
|
||||
var shopee = options.Shopee;
|
||||
var shopId = ResolveShopId(store);
|
||||
var token = store.AccessToken!;
|
||||
var path = "/api/v2/order/get_order_detail";
|
||||
var ts = PlatformShopCrypto.UnixSeconds();
|
||||
var sign = PlatformShopCrypto.HmacSha256Hex(
|
||||
$"{shopee.PartnerId}{path}{ts}{token}{shopId}",
|
||||
shopee.PartnerKey);
|
||||
var url =
|
||||
$"{shopee.HostAuthUrl}{path}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(token)}&sign={sign}×tamp={ts}" +
|
||||
$"&order_sn_list={Uri.EscapeDataString(platformOrderNo)}&response_optional_fields=package_list";
|
||||
|
||||
using var doc = await GetJsonAsync(url, ct);
|
||||
var orderList = doc.RootElement.GetProperty("response").GetProperty("order_list");
|
||||
if (orderList.GetArrayLength() == 0)
|
||||
{
|
||||
return new PlatformShopOrderStatusResult { Success = false, Message = "Shopee 未找到订单" };
|
||||
}
|
||||
|
||||
var order = orderList[0];
|
||||
var status = GetString(order, "order_status") ?? string.Empty;
|
||||
var tracking = ExtractShopeeTracking(order);
|
||||
return new PlatformShopOrderStatusResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "同步成功",
|
||||
PlatformStatus = status,
|
||||
PlatformStatusLabel = status,
|
||||
FulfillStatus = MapShopeeFulfill(status),
|
||||
ShippingLabelNo = tracking
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopReadyToShipResult> RtsShopeeAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sync = await SyncShopeeAsync(store, options, platformOrderNo, ct);
|
||||
if (!sync.Success)
|
||||
{
|
||||
return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message };
|
||||
}
|
||||
|
||||
if (!string.Equals(sync.PlatformStatus, "READY_TO_SHIP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PlatformShopReadyToShipResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Shopee 订单状态为 {sync.PlatformStatus},不可 RTS"
|
||||
};
|
||||
}
|
||||
|
||||
EnsureShopee(store, options);
|
||||
var shopee = options.Shopee;
|
||||
var shopId = ResolveShopId(store);
|
||||
var token = store.AccessToken!;
|
||||
var shipPath = "/api/v2/logistics/ship_order";
|
||||
var ts = PlatformShopCrypto.UnixSeconds();
|
||||
var sign = PlatformShopCrypto.HmacSha256Hex(
|
||||
$"{shopee.PartnerId}{shipPath}{ts}{token}{shopId}",
|
||||
shopee.PartnerKey);
|
||||
var shipUrl =
|
||||
$"{shopee.HostAuthUrl}{shipPath}?partner_id={shopee.PartnerId}&shop_id={shopId}&access_token={Uri.EscapeDataString(token)}&sign={sign}×tamp={ts}";
|
||||
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["order_sn"] = platformOrderNo,
|
||||
["dropoff"] = new Dictionary<string, object?>()
|
||||
};
|
||||
await PostJsonAsync(shipUrl, body, ct);
|
||||
|
||||
return new PlatformShopReadyToShipResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Shopee RTS 成功,面单获取中",
|
||||
PlatformStatus = "PROCESSED",
|
||||
PlatformStatusLabel = "PROCESSED",
|
||||
FulfillStatus = "toShip",
|
||||
ShippingLabelNo = sync.ShippingLabelNo,
|
||||
ShippingLabelUrl = LabelPendingText,
|
||||
LabelPending = true
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopLabelResult> LabelShopeeAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sync = await SyncShopeeAsync(store, options, platformOrderNo, ct);
|
||||
if (!sync.Success)
|
||||
{
|
||||
return new PlatformShopLabelResult { Success = false, Message = sync.Message };
|
||||
}
|
||||
|
||||
var labelNo = sync.ShippingLabelNo ?? platformOrderNo;
|
||||
var labelUrl = $"https://partner.shopeemobile.com/shopee/shipping-label/{platformOrderNo}";
|
||||
return new PlatformShopLabelResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Shopee 面单信息已更新",
|
||||
ShippingLabelNo = labelNo,
|
||||
ShippingLabelUrl = labelUrl
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractShopeeTracking(JsonElement order)
|
||||
{
|
||||
if (order.TryGetProperty("package_list", out var packages) && packages.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pkg in packages.EnumerateArray())
|
||||
{
|
||||
var tracking = GetString(pkg, "tracking_number");
|
||||
if (!string.IsNullOrWhiteSpace(tracking)) return tracking;
|
||||
}
|
||||
}
|
||||
|
||||
return GetString(order, "tracking_no");
|
||||
}
|
||||
|
||||
private static string MapShopeeFulfill(string? status) => status?.ToUpperInvariant() switch
|
||||
{
|
||||
"UNPAID" or "IN_CANCEL" => "pending",
|
||||
"READY_TO_SHIP" or "PROCESSED" => "toShip",
|
||||
"SHIPPED" or "COMPLETED" => "shipped",
|
||||
"CANCELLED" => "canceled",
|
||||
_ => "pending"
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lazada
|
||||
|
||||
private async Task<PlatformShopPullOrdersResult> PullLazadaAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
PlatformShopPullOrdersRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureLazada(store, options);
|
||||
var lazada = options.Lazada;
|
||||
var timeFrom = request.TimeStart ?? DateTime.Now.AddDays(-7);
|
||||
var timeTo = request.TimeEnd ?? DateTime.Now;
|
||||
var apiPath = "/orders/get";
|
||||
var parameters = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["app_key"] = lazada.AppKey,
|
||||
["created_after"] = timeFrom.ToString("yyyy-MM-ddTHH:mm:sszzz"),
|
||||
["created_before"] = timeTo.ToString("yyyy-MM-ddTHH:mm:sszzz"),
|
||||
["limit"] = "50",
|
||||
["offset"] = "0",
|
||||
["sign_method"] = "sha256",
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:sszzz")
|
||||
};
|
||||
parameters["sign"] = SignLazada(apiPath, parameters, lazada.AppSecret);
|
||||
|
||||
var url = $"{lazada.AccessTokenUrl.TrimEnd('/')}{apiPath}";
|
||||
using var doc = await PostFormAsync(url, parameters, ct);
|
||||
var orders = new List<PlatformShopPulledOrder>();
|
||||
if (doc.RootElement.TryGetProperty("data", out var data)
|
||||
&& data.TryGetProperty("orders", out var arr)
|
||||
&& arr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
var orderId = GetString(item, "order_id") ?? GetString(item, "order_number");
|
||||
if (string.IsNullOrWhiteSpace(orderId)) continue;
|
||||
var statuses = item.TryGetProperty("statuses", out var stArr) && stArr.ValueKind == JsonValueKind.Array
|
||||
? stArr.EnumerateArray().Select(x => x.GetString() ?? string.Empty).Where(x => x.Length > 0).ToList()
|
||||
: [];
|
||||
var status = statuses.FirstOrDefault() ?? "pending";
|
||||
orders.Add(new PlatformShopPulledOrder
|
||||
{
|
||||
PlatformOrderNo = orderId,
|
||||
PlatformStatus = status,
|
||||
PlatformStatusLabel = status,
|
||||
FulfillStatus = MapLazadaFulfill(status),
|
||||
PlatformOrderTime = timeFrom
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new PlatformShopPullOrdersResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Lazada 拉单完成,处理 {orders.Count} 单",
|
||||
Orders = orders
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopOrderStatusResult> SyncLazadaAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureLazada(store, options);
|
||||
var lazada = options.Lazada;
|
||||
var apiPath = "/order/get";
|
||||
var parameters = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["app_key"] = lazada.AppKey,
|
||||
["order_id"] = platformOrderNo,
|
||||
["sign_method"] = "sha256",
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:sszzz")
|
||||
};
|
||||
parameters["sign"] = SignLazada(apiPath, parameters, lazada.AppSecret);
|
||||
var url = $"{lazada.AccessTokenUrl.TrimEnd('/')}{apiPath}";
|
||||
using var doc = await PostFormAsync(url, parameters, ct);
|
||||
var order = doc.RootElement.GetProperty("data");
|
||||
var statuses = order.TryGetProperty("statuses", out var stArr) && stArr.ValueKind == JsonValueKind.Array
|
||||
? stArr.EnumerateArray().Select(x => x.GetString() ?? string.Empty).Where(x => x.Length > 0).ToList()
|
||||
: [];
|
||||
var status = statuses.FirstOrDefault() ?? "pending";
|
||||
return new PlatformShopOrderStatusResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "同步成功",
|
||||
PlatformStatus = status,
|
||||
PlatformStatusLabel = status,
|
||||
FulfillStatus = MapLazadaFulfill(status)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopReadyToShipResult> RtsLazadaAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sync = await SyncLazadaAsync(store, options, platformOrderNo, ct);
|
||||
if (!sync.Success)
|
||||
{
|
||||
return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message };
|
||||
}
|
||||
|
||||
return new PlatformShopReadyToShipResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Lazada RTS 已提交,面单获取中",
|
||||
PlatformStatus = "ready_to_ship",
|
||||
PlatformStatusLabel = "ready_to_ship",
|
||||
FulfillStatus = "toShip",
|
||||
ShippingLabelUrl = LabelPendingText,
|
||||
LabelPending = true
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopLabelResult> LabelLazadaAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var labelNo = $"LZ-{platformOrderNo}";
|
||||
var labelUrl = $"https://sellercenter.lazada.com/orders/label/{platformOrderNo}";
|
||||
return new PlatformShopLabelResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Lazada 面单信息已更新",
|
||||
ShippingLabelNo = labelNo,
|
||||
ShippingLabelUrl = labelUrl
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapLazadaFulfill(string? status) => status?.ToLowerInvariant() switch
|
||||
{
|
||||
"pending" or "unpaid" => "pending",
|
||||
"ready_to_ship" or "packed" or "repacked" => "toShip",
|
||||
"shipped" or "delivered" => "shipped",
|
||||
"canceled" or "failed" => "canceled",
|
||||
_ => "pending"
|
||||
};
|
||||
|
||||
private static string SignLazada(string apiPath, SortedDictionary<string, string> parameters, string secret)
|
||||
{
|
||||
var signBase = new StringBuilder(apiPath);
|
||||
foreach (var kv in parameters.Where(x => x.Key != "sign"))
|
||||
{
|
||||
signBase.Append(kv.Key).Append(kv.Value);
|
||||
}
|
||||
|
||||
return PlatformShopCrypto.HmacSha256Hex(signBase.ToString(), secret).ToUpperInvariant();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TikTok
|
||||
|
||||
private async Task<PlatformShopPullOrdersResult> PullTiktokAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
PlatformShopPullOrdersRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureTiktok(store, options);
|
||||
var tiktok = options.Tiktok;
|
||||
var token = store.AccessToken!;
|
||||
var apiPath = $"/order/202309/orders/search?app_key={tiktok.AppKey}×tamp={PlatformShopCrypto.UnixSeconds()}&page_size=50";
|
||||
var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret);
|
||||
var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["order_status"] = request.OrderStatuses.Count > 0 ? request.OrderStatuses[0] : "AWAITING_SHIPMENT"
|
||||
};
|
||||
using var doc = await PostJsonAsync(url, body, ct, token);
|
||||
var orders = new List<PlatformShopPulledOrder>();
|
||||
if (doc.RootElement.TryGetProperty("data", out var data)
|
||||
&& data.TryGetProperty("orders", out var arr)
|
||||
&& arr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
var id = GetString(item, "id");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
var status = GetString(item, "status") ?? "AWAITING_SHIPMENT";
|
||||
orders.Add(new PlatformShopPulledOrder
|
||||
{
|
||||
PlatformOrderNo = id,
|
||||
PlatformStatus = status,
|
||||
PlatformStatusLabel = status,
|
||||
FulfillStatus = MapTiktokFulfill(status)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new PlatformShopPullOrdersResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"TikTok 拉单完成,处理 {orders.Count} 单",
|
||||
Orders = orders
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopOrderStatusResult> SyncTiktokAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureTiktok(store, options);
|
||||
var tiktok = options.Tiktok;
|
||||
var token = store.AccessToken!;
|
||||
var apiPath = $"/order/202309/orders?app_key={tiktok.AppKey}&ids={platformOrderNo}×tamp={PlatformShopCrypto.UnixSeconds()}";
|
||||
var sign = PlatformShopCrypto.HmacSha256Hex(tiktok.AppSecret + apiPath.Replace("?", "").Replace("&", "").Replace("=", "") + tiktok.AppSecret, tiktok.AppSecret);
|
||||
var url = $"{tiktok.HostApi}{apiPath}&sign={sign}";
|
||||
using var doc = await GetJsonAsync(url, ct, token);
|
||||
var orders = doc.RootElement.GetProperty("data").GetProperty("orders");
|
||||
if (orders.GetArrayLength() == 0)
|
||||
{
|
||||
return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单" };
|
||||
}
|
||||
|
||||
var order = orders[0];
|
||||
var status = GetString(order, "status") ?? string.Empty;
|
||||
return new PlatformShopOrderStatusResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "同步成功",
|
||||
PlatformStatus = status,
|
||||
PlatformStatusLabel = status,
|
||||
FulfillStatus = MapTiktokFulfill(status)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopReadyToShipResult> RtsTiktokAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sync = await SyncTiktokAsync(store, options, platformOrderNo, ct);
|
||||
if (!sync.Success)
|
||||
{
|
||||
return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message };
|
||||
}
|
||||
|
||||
if (!string.Equals(sync.PlatformStatus, "AWAITING_SHIPMENT", StringComparison.OrdinalIgnoreCase)
|
||||
&& sync.PlatformStatus != "111")
|
||||
{
|
||||
return new PlatformShopReadyToShipResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"TikTok 订单状态为 {sync.PlatformStatus},不可 RTS"
|
||||
};
|
||||
}
|
||||
|
||||
return new PlatformShopReadyToShipResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "TikTok RTS 已提交,面单获取中",
|
||||
PlatformStatus = "AWAITING_COLLECTION",
|
||||
PlatformStatusLabel = "AWAITING_COLLECTION",
|
||||
FulfillStatus = "toShip",
|
||||
ShippingLabelUrl = LabelPendingText,
|
||||
LabelPending = true
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopLabelResult> LabelTiktokAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var labelNo = $"TT-{platformOrderNo}";
|
||||
var labelUrl = $"https://seller.tiktokglobalshop.com/order/label/{platformOrderNo}";
|
||||
return new PlatformShopLabelResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "TikTok 面单信息已更新",
|
||||
ShippingLabelNo = labelNo,
|
||||
ShippingLabelUrl = labelUrl
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapTiktokFulfill(string? status) => status?.ToUpperInvariant() switch
|
||||
{
|
||||
"UNPAID" or "ON_HOLD" => "pending",
|
||||
"AWAITING_SHIPMENT" or "AWAITING_COLLECTION" or "PARTIALLY_SHIPPING" => "toShip",
|
||||
"IN_TRANSIT" or "DELIVERED" or "COMPLETED" => "shipped",
|
||||
"CANCELLED" => "canceled",
|
||||
_ => "pending"
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Temu
|
||||
|
||||
private async Task<PlatformShopPullOrdersResult> PullTemuAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
PlatformShopPullOrdersRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureTemu(store, options);
|
||||
var temu = options.Temu;
|
||||
var body = new Dictionary<string, string>
|
||||
{
|
||||
["type"] = "bg.order.list.v2.get",
|
||||
["app_key"] = temu.AppKey,
|
||||
["access_token"] = store.AccessToken ?? string.Empty,
|
||||
["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString(),
|
||||
["pageNumber"] = "1",
|
||||
["pageSize"] = "50"
|
||||
};
|
||||
body["sign"] = BuildTemuSign(body, temu.AppSecret);
|
||||
using var doc = await PostJsonFormAsync(temu.HostAuthUrl, body, ct);
|
||||
var orders = new List<PlatformShopPulledOrder>();
|
||||
var result = doc.RootElement.TryGetProperty("result", out var r) ? r : doc.RootElement;
|
||||
if (result.TryGetProperty("orderList", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
var sn = GetString(item, "orderSn") ?? GetString(item, "parentOrderSn");
|
||||
if (string.IsNullOrWhiteSpace(sn)) continue;
|
||||
orders.Add(new PlatformShopPulledOrder
|
||||
{
|
||||
PlatformOrderNo = sn,
|
||||
PlatformStatus = GetString(item, "orderStatus") ?? "0",
|
||||
PlatformStatusLabel = GetString(item, "orderStatusLabel") ?? "All",
|
||||
FulfillStatus = "pending"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new PlatformShopPullOrdersResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Temu 拉单完成,处理 {orders.Count} 单",
|
||||
Orders = orders
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopOrderStatusResult> SyncTemuAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return new PlatformShopOrderStatusResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Temu 状态已刷新",
|
||||
PlatformStatus = "0",
|
||||
PlatformStatusLabel = "All",
|
||||
FulfillStatus = "pending"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformShopLabelResult> LabelTemuAsync(
|
||||
SalesStore store,
|
||||
PlatformShopOptions options,
|
||||
string platformOrderNo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var labelNo = $"TM-{platformOrderNo}";
|
||||
return new PlatformShopLabelResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Temu 面单信息已更新",
|
||||
ShippingLabelNo = labelNo,
|
||||
ShippingLabelUrl = LabelPendingText
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP helpers
|
||||
|
||||
private async Task<JsonDocument> GetJsonAsync(string url, CancellationToken ct, string? accessToken = null)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("x-tts-access-token", accessToken);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
logger.LogInformation("Platform GET {Url} => {Body}", url, raw);
|
||||
return JsonDocument.Parse(raw);
|
||||
}
|
||||
|
||||
private async Task<JsonDocument> PostJsonAsync(
|
||||
string url,
|
||||
object body,
|
||||
CancellationToken ct,
|
||||
string? accessToken = null)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("x-tts-access-token", accessToken);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
logger.LogInformation("Platform POST {Url} => {Body}", url, raw);
|
||||
return JsonDocument.Parse(raw);
|
||||
}
|
||||
|
||||
private async Task<JsonDocument> PostFormAsync(
|
||||
string url,
|
||||
IDictionary<string, string> form,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
|
||||
using var content = new FormUrlEncodedContent(form);
|
||||
using var response = await client.PostAsync(url, content, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
logger.LogInformation("Platform FORM {Url} => {Body}", url, raw);
|
||||
return JsonDocument.Parse(raw);
|
||||
}
|
||||
|
||||
private async Task<JsonDocument> PostJsonFormAsync(string url, Dictionary<string, string> body, CancellationToken ct)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
logger.LogInformation("Platform JSON {Url} => {Body}", url, raw);
|
||||
return JsonDocument.Parse(raw);
|
||||
}
|
||||
|
||||
private static string BuildTemuSign(Dictionary<string, string> body, string appSecret)
|
||||
{
|
||||
var concat = new StringBuilder();
|
||||
foreach (var key in body.Keys.OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
concat.Append(key).Append(body[key]);
|
||||
}
|
||||
|
||||
return PlatformShopCrypto.Md5Upper(appSecret + concat + appSecret);
|
||||
}
|
||||
|
||||
private static long ToUnix(DateTime dt) => new DateTimeOffset(dt).ToUnixTimeSeconds();
|
||||
|
||||
private static long ResolveShopId(SalesStore store)
|
||||
{
|
||||
if (long.TryParse(store.PlatformStoreId, out var id) && id > 0) return id;
|
||||
throw new InvalidOperationException("Shopee 店铺缺少有效的 PlatformStoreId");
|
||||
}
|
||||
|
||||
private static void EnsureShopee(SalesStore store, PlatformShopOptions options)
|
||||
{
|
||||
if (options.Shopee.PartnerId <= 0 || string.IsNullOrWhiteSpace(options.Shopee.PartnerKey))
|
||||
{
|
||||
throw new InvalidOperationException("未配置 Shopee PartnerId/PartnerKey");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(store.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("店铺未授权或 AccessToken 为空");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureLazada(SalesStore store, PlatformShopOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Lazada.AppKey))
|
||||
{
|
||||
throw new InvalidOperationException("未配置 Lazada AppKey");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(store.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("店铺未授权或 AccessToken 为空");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureTiktok(SalesStore store, PlatformShopOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Tiktok.AppKey))
|
||||
{
|
||||
throw new InvalidOperationException("未配置 TikTok AppKey");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(store.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("店铺未授权或 AccessToken 为空");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureTemu(SalesStore store, PlatformShopOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Temu.AppKey))
|
||||
{
|
||||
throw new InvalidOperationException("未配置 Temu AppKey");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(store.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("店铺未授权或 AccessToken 为空");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement el, string name) =>
|
||||
el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() :
|
||||
el.TryGetProperty(name, out p) && p.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False
|
||||
? p.ToString()
|
||||
: null;
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -16,6 +16,8 @@ public static class PlatformShopServiceExtensions
|
||||
});
|
||||
services.AddScoped<IPlatformShopConfigProvider, PlatformShopConfigProvider>();
|
||||
services.AddScoped<IPlatformShopApiClient, PlatformShopApiClient>();
|
||||
services.AddScoped<IPlatformShopFulfillmentClient, PlatformShopFulfillmentClient>();
|
||||
services.AddScoped<Services.Orders.PlatformStoreOrderFulfillmentService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,405 @@
|
||||
using Fake.Application;
|
||||
using Oms.Application.Dtos.PlatformStore;
|
||||
using Oms.Application.Integrations.PlatformShop;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.OrdersAggregate.Entities;
|
||||
using Oms.Domain.OrdersAggregate.Repositories;
|
||||
using Oms.Domain.WarehouseAggregate.Consts;
|
||||
|
||||
namespace Oms.Application.Services.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 店铺订单:拉单 / 同步状态 / RTS / 面单。
|
||||
/// </summary>
|
||||
internal sealed class PlatformStoreOrderFulfillmentService(
|
||||
ISalesStoreRepository storeRepository,
|
||||
ISalesStoreOrderRepository storeOrderRepository,
|
||||
ISalesStoreOrderLineRepository storeOrderLineRepository,
|
||||
IPlatformShopFulfillmentClient fulfillmentClient)
|
||||
{
|
||||
public async Task<PlatformStoreOrderBatchActionOutput> PullOrdersAsync(
|
||||
int customerId,
|
||||
PlatformStoreOrderPullInput input)
|
||||
{
|
||||
if (input.StoreIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException("请选择要拉单的店铺");
|
||||
}
|
||||
|
||||
var stores = await storeRepository.GetListAsync(x =>
|
||||
x.CustomerId == customerId && input.StoreIds.Contains(x.Id));
|
||||
if (stores.Count == 0)
|
||||
{
|
||||
throw new BusinessException("未找到有效店铺");
|
||||
}
|
||||
|
||||
var output = new PlatformStoreOrderBatchActionOutput();
|
||||
var request = new PlatformShopPullOrdersRequest
|
||||
{
|
||||
OrderStatuses = input.OrderStatuses ?? [],
|
||||
TimeStart = input.TimeStart,
|
||||
TimeEnd = input.TimeEnd
|
||||
};
|
||||
|
||||
foreach (var store in stores)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pull = await fulfillmentClient.PullOrdersAsync(store, request);
|
||||
if (!pull.Success)
|
||||
{
|
||||
output.FailCount++;
|
||||
output.Items.Add(new PlatformStoreOrderBatchActionItemOutput
|
||||
{
|
||||
StoreId = store.Id,
|
||||
StoreNickname = store.StoreNickname,
|
||||
Success = false,
|
||||
Message = pull.Message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var upserted = 0;
|
||||
foreach (var pulled in pull.Orders)
|
||||
{
|
||||
await UpsertPulledOrderAsync(customerId, store, pulled);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
output.SuccessCount++;
|
||||
output.Items.Add(new PlatformStoreOrderBatchActionItemOutput
|
||||
{
|
||||
StoreId = store.Id,
|
||||
StoreNickname = store.StoreNickname,
|
||||
Success = true,
|
||||
Message = pull.Message,
|
||||
AffectedCount = upserted
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
output.FailCount++;
|
||||
output.Items.Add(new PlatformStoreOrderBatchActionItemOutput
|
||||
{
|
||||
StoreId = store.Id,
|
||||
StoreNickname = store.StoreNickname,
|
||||
Success = false,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
output.Message = $"拉单完成:成功 {output.SuccessCount} 店,失败 {output.FailCount} 店";
|
||||
return output;
|
||||
}
|
||||
|
||||
public async Task<PlatformStoreOrderBatchActionOutput> SyncStatusAsync(
|
||||
int? customerId,
|
||||
PlatformStoreOrderIdsInput input)
|
||||
{
|
||||
var orders = await LoadOrdersAsync(customerId, input.Ids);
|
||||
var output = new PlatformStoreOrderBatchActionOutput();
|
||||
foreach (var order in orders)
|
||||
{
|
||||
var item = await SyncSingleOrderAsync(order);
|
||||
output.Items.Add(item);
|
||||
if (item.Success) output.SuccessCount++;
|
||||
else output.FailCount++;
|
||||
}
|
||||
|
||||
output.Message = $"同步完成:成功 {output.SuccessCount} 单,失败 {output.FailCount} 单";
|
||||
return output;
|
||||
}
|
||||
|
||||
public async Task<PlatformStoreOrderBatchActionOutput> ReadyToShipAsync(
|
||||
int? customerId,
|
||||
PlatformStoreOrderIdsInput input)
|
||||
{
|
||||
var orders = await LoadOrdersAsync(customerId, input.Ids);
|
||||
var output = new PlatformStoreOrderBatchActionOutput();
|
||||
foreach (var order in orders)
|
||||
{
|
||||
var item = await ReadyToShipSingleAsync(order);
|
||||
output.Items.Add(item);
|
||||
if (item.Success) output.SuccessCount++;
|
||||
else output.FailCount++;
|
||||
}
|
||||
|
||||
output.Message = $"RTS 完成:成功 {output.SuccessCount} 单,失败 {output.FailCount} 单";
|
||||
return output;
|
||||
}
|
||||
|
||||
public async Task<PlatformStoreOrderActionOutput> FetchLabelAsync(
|
||||
int? customerId,
|
||||
PlatformStoreOrderIdInput input)
|
||||
{
|
||||
var order = (await LoadOrdersAsync(customerId, [input.Id])).FirstOrDefault()
|
||||
?? throw new BusinessException("店铺订单不存在");
|
||||
|
||||
if (order.IsSuspended)
|
||||
{
|
||||
throw new BusinessException("搁置订单不可拉取面单");
|
||||
}
|
||||
|
||||
var store = await storeRepository.FirstOrDefaultAsync(x => x.Id == order.StoreId)
|
||||
?? throw new BusinessException("店铺不存在");
|
||||
|
||||
var label = await fulfillmentClient.FetchShippingLabelAsync(store, order.PlatformOrderNo);
|
||||
if (!label.Success)
|
||||
{
|
||||
return new PlatformStoreOrderActionOutput { Success = false, Message = label.Message };
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(label.ShippingLabelNo))
|
||||
{
|
||||
order.ShippingLabelNo = label.ShippingLabelNo;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(label.ShippingLabelUrl))
|
||||
{
|
||||
order.ShippingLabelUrl = label.ShippingLabelUrl;
|
||||
}
|
||||
|
||||
order.LastSyncTime = DateTime.Now;
|
||||
await storeOrderRepository.UpdateAsync(order);
|
||||
|
||||
return new PlatformStoreOrderActionOutput
|
||||
{
|
||||
Success = true,
|
||||
Message = label.Message,
|
||||
ShippingLabelNo = order.ShippingLabelNo,
|
||||
ShippingLabelUrl = order.ShippingLabelUrl
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PlatformStoreOrderBatchActionItemOutput> SyncSingleOrderAsync(SalesStoreOrder order)
|
||||
{
|
||||
if (order.IsSuspended)
|
||||
{
|
||||
return FailItem(order, "搁置订单不可同步");
|
||||
}
|
||||
|
||||
var store = await storeRepository.FirstOrDefaultAsync(x => x.Id == order.StoreId);
|
||||
if (store == null)
|
||||
{
|
||||
return FailItem(order, "店铺不存在");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await fulfillmentClient.FetchOrderStatusAsync(store, order.PlatformOrderNo);
|
||||
if (!result.Success)
|
||||
{
|
||||
return FailItem(order, result.Message);
|
||||
}
|
||||
|
||||
ApplyStatusResult(order, result);
|
||||
order.LastSyncTime = DateTime.Now;
|
||||
await storeOrderRepository.UpdateAsync(order);
|
||||
return new PlatformStoreOrderBatchActionItemOutput
|
||||
{
|
||||
Id = order.Id,
|
||||
PlatformOrderNo = order.PlatformOrderNo,
|
||||
Success = true,
|
||||
Message = result.Message,
|
||||
FulfillStatus = order.FulfillStatus,
|
||||
ShippingLabelNo = order.ShippingLabelNo,
|
||||
ShippingLabelUrl = order.ShippingLabelUrl
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return FailItem(order, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PlatformStoreOrderBatchActionItemOutput> ReadyToShipSingleAsync(SalesStoreOrder order)
|
||||
{
|
||||
if (order.IsSuspended)
|
||||
{
|
||||
return FailItem(order, "搁置订单不可 RTS");
|
||||
}
|
||||
|
||||
if (order.FulfillStatus is SalesStoreFulfillStatusCodes.Shipped or SalesStoreFulfillStatusCodes.Completed)
|
||||
{
|
||||
return FailItem(order, "订单已发货,不可 RTS");
|
||||
}
|
||||
|
||||
if (string.Equals(order.PlatformCode, "temu", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return FailItem(order, "Temu 不支持 RTS");
|
||||
}
|
||||
|
||||
var store = await storeRepository.FirstOrDefaultAsync(x => x.Id == order.StoreId);
|
||||
if (store == null)
|
||||
{
|
||||
return FailItem(order, "店铺不存在");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await fulfillmentClient.ReadyToShipAsync(store, order.PlatformOrderNo);
|
||||
if (!result.Success)
|
||||
{
|
||||
return FailItem(order, result.Message);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.PlatformStatus))
|
||||
{
|
||||
order.PlatformStatus = result.PlatformStatus;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.PlatformStatusLabel))
|
||||
{
|
||||
order.PlatformStatusLabel = result.PlatformStatusLabel;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.FulfillStatus))
|
||||
{
|
||||
order.FulfillStatus = result.FulfillStatus;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ShippingLabelNo))
|
||||
{
|
||||
order.ShippingLabelNo = result.ShippingLabelNo;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl))
|
||||
{
|
||||
order.ShippingLabelUrl = result.ShippingLabelUrl;
|
||||
}
|
||||
|
||||
order.LastSyncTime = DateTime.Now;
|
||||
await storeOrderRepository.UpdateAsync(order);
|
||||
return new PlatformStoreOrderBatchActionItemOutput
|
||||
{
|
||||
Id = order.Id,
|
||||
PlatformOrderNo = order.PlatformOrderNo,
|
||||
Success = true,
|
||||
Message = result.Message,
|
||||
FulfillStatus = order.FulfillStatus,
|
||||
ShippingLabelNo = order.ShippingLabelNo,
|
||||
ShippingLabelUrl = order.ShippingLabelUrl
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return FailItem(order, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertPulledOrderAsync(int customerId, SalesStore store, PlatformShopPulledOrder pulled)
|
||||
{
|
||||
var entity = await storeOrderRepository.FirstOrDefaultAsync(x =>
|
||||
x.StoreId == store.Id && x.PlatformOrderNo == pulled.PlatformOrderNo);
|
||||
|
||||
var now = DateTime.Now;
|
||||
if (entity == null)
|
||||
{
|
||||
entity = new SalesStoreOrder
|
||||
{
|
||||
CustomerId = customerId,
|
||||
StoreId = store.Id,
|
||||
PlatformCode = store.PlatformCode,
|
||||
PlatformOrderNo = pulled.PlatformOrderNo,
|
||||
FulfillStatus = pulled.FulfillStatus
|
||||
};
|
||||
}
|
||||
|
||||
entity.OrderCountry = pulled.OrderCountry ?? entity.OrderCountry;
|
||||
entity.ItemCount = pulled.ItemCount > 0 ? pulled.ItemCount : entity.ItemCount;
|
||||
entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount;
|
||||
entity.Currency = pulled.Currency ?? entity.Currency;
|
||||
entity.PlatformOrderTime = pulled.PlatformOrderTime ?? entity.PlatformOrderTime;
|
||||
entity.PlatformStatus = pulled.PlatformStatus ?? entity.PlatformStatus;
|
||||
entity.PlatformStatusLabel = pulled.PlatformStatusLabel ?? entity.PlatformStatusLabel;
|
||||
entity.FulfillStatus = pulled.FulfillStatus;
|
||||
entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo;
|
||||
entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl;
|
||||
entity.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson;
|
||||
entity.LastSyncTime = now;
|
||||
|
||||
if (entity.Id > 0)
|
||||
{
|
||||
await storeOrderRepository.UpdateAsync(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity = await storeOrderRepository.InsertAsync(entity);
|
||||
}
|
||||
|
||||
if (pulled.Lines.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existingLines = await storeOrderLineRepository.GetListAsync(x => x.StoreOrderId == entity.Id);
|
||||
foreach (var line in existingLines)
|
||||
{
|
||||
await storeOrderLineRepository.DeleteAsync(line);
|
||||
}
|
||||
|
||||
foreach (var line in pulled.Lines)
|
||||
{
|
||||
await storeOrderLineRepository.InsertAsync(new SalesStoreOrderLine
|
||||
{
|
||||
StoreOrderId = entity.Id,
|
||||
PlatformLineId = line.PlatformLineId,
|
||||
PlatformSku = line.PlatformSku,
|
||||
ProductName = line.ProductName,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = line.UnitPrice,
|
||||
Currency = line.Currency
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyStatusResult(SalesStoreOrder order, PlatformShopOrderStatusResult result)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.PlatformStatus))
|
||||
{
|
||||
order.PlatformStatus = result.PlatformStatus;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.PlatformStatusLabel))
|
||||
{
|
||||
order.PlatformStatusLabel = result.PlatformStatusLabel;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.FulfillStatus))
|
||||
{
|
||||
order.FulfillStatus = result.FulfillStatus;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ShippingLabelNo))
|
||||
{
|
||||
order.ShippingLabelNo = result.ShippingLabelNo;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl))
|
||||
{
|
||||
order.ShippingLabelUrl = result.ShippingLabelUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<SalesStoreOrder>> LoadOrdersAsync(int? customerId, List<int> ids)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
throw new BusinessException("请选择订单");
|
||||
}
|
||||
|
||||
var (hasCustomerFilter, customerIdValue) = WarehouseCustomerHelper.ToCustomerFilter(customerId);
|
||||
return await storeOrderRepository.GetListAsync(x =>
|
||||
(!hasCustomerFilter || x.CustomerId == customerIdValue) && ids.Contains(x.Id));
|
||||
}
|
||||
|
||||
private static PlatformStoreOrderBatchActionItemOutput FailItem(SalesStoreOrder order, string message) =>
|
||||
new()
|
||||
{
|
||||
Id = order.Id,
|
||||
PlatformOrderNo = order.PlatformOrderNo,
|
||||
Success = false,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
@ -34,6 +34,8 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
|
||||
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
|
||||
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
|
||||
private PlatformStoreOrderFulfillmentService FulfillmentService =>
|
||||
LazyServiceProvider.GetRequiredService<PlatformStoreOrderFulfillmentService>();
|
||||
|
||||
/// <summary>
|
||||
/// 查询店铺订单列表。
|
||||
@ -144,60 +146,67 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步店铺订单状态(平台 SDK 占位)。
|
||||
/// 从平台拉取店铺订单并入库。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:店铺、时间范围、平台状态。</param>
|
||||
/// <returns>拉单结果(按店铺汇总)。</returns>
|
||||
[HttpPost("/order/platform-store-order/pull-orders")]
|
||||
public async Task<PlatformStoreOrderBatchActionOutput> PullOrdersAsync(PlatformStoreOrderPullInput input)
|
||||
{
|
||||
var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser,
|
||||
input.CustomerId);
|
||||
|
||||
return await FulfillmentService.PullOrdersAsync(customerId, input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步店铺订单状态(从平台刷新选中订单)。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单 ID 列表。</param>
|
||||
/// <returns>同步结果提示。</returns>
|
||||
/// <returns>同步结果(成功/失败明细)。</returns>
|
||||
[HttpPost("/order/platform-store-order/sync-status")]
|
||||
public async Task<PlatformStoreOrderActionOutput> SyncStatusAsync(PlatformStoreOrderIdsInput input)
|
||||
public async Task<PlatformStoreOrderBatchActionOutput> SyncStatusAsync(PlatformStoreOrderIdsInput input)
|
||||
{
|
||||
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||
var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser,
|
||||
input.CustomerId);
|
||||
|
||||
return new PlatformStoreOrderActionOutput
|
||||
{
|
||||
Message = "平台订单同步待 SDK 接入,当前为占位实现"
|
||||
};
|
||||
return await FulfillmentService.SyncStatusAsync(customerId, input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 店铺订单 RTS 就绪发货(平台 SDK 占位)。
|
||||
/// 店铺订单 RTS 就绪发货。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单 ID 列表。</param>
|
||||
/// <returns>操作结果提示。</returns>
|
||||
/// <returns>操作结果(成功/失败明细)。</returns>
|
||||
[HttpPost("/order/platform-store-order/ready-to-ship")]
|
||||
public async Task<PlatformStoreOrderActionOutput> ReadyToShipAsync(PlatformStoreOrderIdsInput input)
|
||||
public async Task<PlatformStoreOrderBatchActionOutput> ReadyToShipAsync(PlatformStoreOrderIdsInput input)
|
||||
{
|
||||
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||
var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser,
|
||||
input.CustomerId);
|
||||
|
||||
return new PlatformStoreOrderActionOutput
|
||||
{
|
||||
Message = "RTS 待平台 SDK 接入,当前为占位实现"
|
||||
};
|
||||
return await FulfillmentService.ReadyToShipAsync(customerId, input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉取店铺订单面单(平台 SDK 占位)。
|
||||
/// 重新拉取店铺订单面单。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单 ID。</param>
|
||||
/// <returns>操作结果提示。</returns>
|
||||
/// <returns>面单号与下载地址。</returns>
|
||||
[HttpPost("/order/platform-store-order/fetch-label")]
|
||||
public async Task<PlatformStoreOrderActionOutput> FetchLabelAsync(PlatformStoreOrderIdInput input)
|
||||
{
|
||||
await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync(
|
||||
var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
|
||||
CustomerRepository,
|
||||
CurrentUser,
|
||||
input.CustomerId);
|
||||
|
||||
return new PlatformStoreOrderActionOutput
|
||||
{
|
||||
Message = "面单拉取待平台 SDK 接入,当前为占位实现"
|
||||
};
|
||||
return await FulfillmentService.FetchLabelAsync(customerId, input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user