From 5afd204d368e122ba41a8eed895ea91bd9a67663 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 15:17:46 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20TikTok=20=E6=8B=89=E5=8D=95=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20shop=5Fcipher=20=E4=B8=8E=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=EF=BC=8C=E5=B9=B6=E6=8C=89=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../PlatformShop/PlatformShopAuthState.cs | 23 ++ .../PlatformShopFulfillmentClient.cs | 245 ++++++++++++++++-- 2 files changed, 242 insertions(+), 26 deletions(-) diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs index d12b923..9fc17ad 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopAuthState.cs @@ -20,6 +20,29 @@ internal static class PlatformShopCrypto } public static long UnixSeconds() => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + /// + /// TikTok Shop 202309 签名,对齐 WMS TiktokApi.makeTiktokSign: + /// 查询参数按 key 排序后拼接 key+value,有请求体则原样接在后面,两侧包 appSecret,HMAC-SHA256 十六进制。 + /// + public static string TiktokSign(string path, IReadOnlyDictionary query, string? body, string appSecret) + { + var input = new StringBuilder(path); + foreach (var key in query.Keys + .Where(k => k is not ("sign" or "access_token")) + .OrderBy(k => k, StringComparer.Ordinal)) + { + input.Append(key).Append(query[key]); + } + + if (!string.IsNullOrEmpty(body)) + { + input.Append(body); + } + + var payload = appSecret + input + appSecret; + return HmacSha256Hex(payload, appSecret); + } } /// diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index b4d6dea..488d6a3 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -582,44 +582,212 @@ internal sealed class PlatformShopFulfillmentClient( 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 + var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); + if (string.IsNullOrWhiteSpace(shopCipher)) { - ["order_status"] = request.OrderStatuses.Count > 0 ? request.OrderStatuses[0] : "AWAITING_SHIPMENT" - }; - using var doc = await PostJsonAsync(url, body, ct, token); - var orders = new List(); - if (doc.RootElement.TryGetProperty("data", out var data) - && data.TryGetProperty("orders", out var arr) - && arr.ValueKind == JsonValueKind.Array) - { - foreach (var item in arr.EnumerateArray()) + return new PlatformShopPullOrdersResult { - var id = GetString(item, "id"); - if (string.IsNullOrWhiteSpace(id)) continue; - var status = GetString(item, "status") ?? "AWAITING_SHIPMENT"; - orders.Add(new PlatformShopPulledOrder + Success = false, + Message = "未获取到 TikTok shop_cipher,请重新授权店铺" + }; + } + + var statuses = NormalizeTiktokPullStatuses(request.OrderStatuses); + var orders = new Dictionary(StringComparer.Ordinal); + var errors = new List(); + + foreach (var statusFilter in statuses) + { + var pageToken = ""; + for (var page = 0; page < 40; page++) + { + using var doc = await PostTiktokOrderSearchAsync( + store, options, request, statusFilter, shopCipher, pageToken, ct); + var root = doc.RootElement; + var code = root.ValueKind == JsonValueKind.Object ? GetString(root, "code") : null; + if (root.ValueKind != JsonValueKind.Object + || code is not ("0" or null) + || !root.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Object) { - PlatformOrderNo = id, - PlatformStatus = status, - PlatformStatusLabel = status, - FulfillStatus = MapTiktokFulfill(status) - }); + var message = root.ValueKind == JsonValueKind.Object + ? GetString(root, "message") ?? GetString(root, "msg") + : null; + errors.Add(string.IsNullOrWhiteSpace(message) + ? $"{statusFilter} 返回为空" + : $"{statusFilter} {code} {message}".Trim()); + break; + } + + if (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) || orders.ContainsKey(id)) continue; + var status = GetString(item, "status") ?? statusFilter; + orders[id] = new PlatformShopPulledOrder + { + PlatformOrderNo = id, + PlatformStatus = status, + PlatformStatusLabel = status, + FulfillStatus = MapTiktokFulfill(status) + }; + } + } + + pageToken = GetString(data, "next_page_token") ?? ""; + if (string.IsNullOrEmpty(pageToken)) + { + break; + } } } + if (orders.Count == 0 && errors.Count > 0) + { + return new PlatformShopPullOrdersResult + { + Success = false, + Message = string.Join(";", errors.Distinct()) + }; + } + + var messageText = $"TikTok 拉单完成,处理 {orders.Count} 单"; + if (errors.Count > 0) + { + messageText += ";" + string.Join(";", errors.Distinct()); + } + return new PlatformShopPullOrdersResult { Success = true, - Message = $"TikTok 拉单完成,处理 {orders.Count} 单", - Orders = orders + Message = messageText, + Orders = orders.Values.ToList() }; } + /// + /// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。 + /// + private async Task GetTiktokShopCipherAsync(SalesStore store, PlatformShopOptions options, CancellationToken ct) + { + var tiktok = options.Tiktok; + const string path = "/authorization/202309/shops"; + var query = new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }; + var sign = PlatformShopCrypto.TiktokSign(path, query, null, tiktok.AppSecret); + var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}"; + using var doc = await GetJsonAsync(url, ct, store.AccessToken); + if (doc.RootElement.ValueKind != JsonValueKind.Object + || !doc.RootElement.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Object + || !data.TryGetProperty("shops", out var shops) + || shops.ValueKind != JsonValueKind.Array) + { + var message = doc.RootElement.ValueKind == JsonValueKind.Object + ? GetString(doc.RootElement, "message") + : null; + logger.LogWarning("TikTok shops 未返回 cipher:{Message}", message); + return null; + } + + string? fallback = null; + foreach (var shop in shops.EnumerateArray()) + { + var cipher = GetString(shop, "cipher"); + if (string.IsNullOrWhiteSpace(cipher)) continue; + fallback ??= cipher; + var id = GetString(shop, "id") ?? GetString(shop, "shop_id"); + if (!string.IsNullOrWhiteSpace(store.PlatformStoreId) + && string.Equals(id, store.PlatformStoreId, StringComparison.Ordinal)) + { + return cipher; + } + } + + return fallback; + } + + /// + /// 按单个 TikTok order_status 搜索一页订单。签名和字段对齐 WMS TiktokApi.getOrdersSearch。 + /// + private async Task PostTiktokOrderSearchAsync( + SalesStore store, + PlatformShopOptions options, + PlatformShopPullOrdersRequest request, + string orderStatus, + string shopCipher, + string pageToken, + CancellationToken ct) + { + var tiktok = options.Tiktok; + const string path = "/order/202309/orders/search"; + var query = new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["page_size"] = "50", + ["page_token"] = pageToken, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }; + var body = new Dictionary + { + ["order_status"] = orderStatus + }; + if (request.TimeStart.HasValue) + { + body["create_time_ge"] = ToUnix(request.TimeStart.Value); + } + + if (request.TimeEnd.HasValue) + { + body["create_time_lt"] = ToUnix(request.TimeEnd.Value.AddSeconds(1)); + } + + var bodyJson = JsonSerializer.Serialize(body); + var sign = PlatformShopCrypto.TiktokSign(path, query, bodyJson, tiktok.AppSecret); + var url = $"{tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}"; + return await PostRawJsonAsync(url, bodyJson, ct, store.AccessToken); + } + + private static string BuildTiktokQuery(IReadOnlyDictionary query, string sign) + { + var parts = query + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}") + .ToList(); + parts.Add($"sign={Uri.EscapeDataString(sign)}"); + return string.Join("&", parts); + } + + private static readonly string[] TiktokPullStatuses = + [ + "UNPAID", + "ON_HOLD", + "PARTIALLY_SHIPPING", + "AWAITING_SHIPMENT", + "AWAITING_COLLECTION", + "IN_TRANSIT", + "DELIVERED", + "COMPLETED", + "CANCELLED" + ]; + + private static List NormalizeTiktokPullStatuses(IEnumerable? selected) + { + var allowed = new HashSet(TiktokPullStatuses, StringComparer.OrdinalIgnoreCase); + var normalized = (selected ?? []) + .Select(x => (x ?? string.Empty).Trim().ToUpperInvariant()) + .Where(allowed.Contains) + .Distinct(StringComparer.Ordinal) + .ToList(); + return normalized.Count > 0 ? normalized : [.. TiktokPullStatuses]; + } + private async Task SyncTiktokAsync( SalesStore store, PlatformShopOptions options, @@ -833,6 +1001,31 @@ internal sealed class PlatformShopFulfillmentClient( return JsonDocument.Parse(raw); } + /// + /// 发送已经参与签名的原始 JSON,避免二次序列化改掉请求体。 + /// + private async Task PostRawJsonAsync( + string url, + string bodyJson, + CancellationToken ct, + string? accessToken = null) + { + var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName); + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(bodyJson, Encoding.UTF8, "application/json") + }; + 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(string.IsNullOrWhiteSpace(raw) ? "{}" : raw); + } + private async Task PostFormAsync( string url, IDictionary form, From 8f64bbf02da98c57ade7d08e81ece8b3619bc474 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 15:54:22 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E5=9B=BD=E5=AE=B6=E5=9C=B0?= =?UTF-8?q?=E5=8C=BA=E6=94=AF=E6=8C=81=E5=A2=9E=E5=88=A0=E6=94=B9=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=EF=BC=8C=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=92=8C=E6=9B=B4=E6=96=B0=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/System/CountryImportInput.cs | 25 ++ .../Dtos/System/CountryImportOutput.cs | 48 ++++ .../Dtos/System/CountryOutput.cs | 10 + .../Dtos/System/CreateCountryInput.cs | 27 ++ .../Dtos/System/UpdateCountryInput.cs | 33 +++ .../Services/System/CountryService.cs | 248 +++++++++++++++++- .../Shared/CountryImportHelper.cs | 179 +++++++++++++ .../Repositories/ICountryRepository.cs | 5 + .../System/CountryRepository.cs | 11 + 9 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 src/Oms.Application/Dtos/System/CountryImportInput.cs create mode 100644 src/Oms.Application/Dtos/System/CountryImportOutput.cs create mode 100644 src/Oms.Application/Dtos/System/CreateCountryInput.cs create mode 100644 src/Oms.Application/Dtos/System/UpdateCountryInput.cs create mode 100644 src/Oms.Application/Shared/CountryImportHelper.cs diff --git a/src/Oms.Application/Dtos/System/CountryImportInput.cs b/src/Oms.Application/Dtos/System/CountryImportInput.cs new file mode 100644 index 0000000..5340a37 --- /dev/null +++ b/src/Oms.Application/Dtos/System/CountryImportInput.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace Oms.Application.Dtos.System; + +/// +/// 国家/地区 Excel 导入入参。 +/// +public class CountryImportInput +{ + /// + /// 导入模式:incremental=按二字码新增或更新;full=按二字码新增或更新,并软删除文件中未出现的国家。 + /// + public string ImportMode { get; set; } = ImportModes.Incremental; + + /// + /// Excel 文件。 + /// + public IFormFile File { get; set; } = null!; + + public static class ImportModes + { + public const string Incremental = "incremental"; + public const string Full = "full"; + } +} diff --git a/src/Oms.Application/Dtos/System/CountryImportOutput.cs b/src/Oms.Application/Dtos/System/CountryImportOutput.cs new file mode 100644 index 0000000..6c7ffee --- /dev/null +++ b/src/Oms.Application/Dtos/System/CountryImportOutput.cs @@ -0,0 +1,48 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 国家/地区 Excel 导入结果。 +/// +public class CountryImportOutput +{ + /// + /// 成功写入条数。 + /// + public int SuccessCount { get; set; } + + /// + /// 跳过条数。 + /// + public int SkipCount { get; set; } + + /// + /// 失败条数。 + /// + public int FailCount { get; set; } + + /// + /// 全量导入时软删除的条数。 + /// + public int DeletedCount { get; set; } + + /// + /// 失败行明细。 + /// + public List Failures { get; set; } = []; +} + +/// +/// 导入失败行。 +/// +public class CountryImportFailRow +{ + /// + /// Excel 行号(含表头,从 2 起为数据行)。 + /// + public int RowNumber { get; set; } + + /// + /// 失败原因。 + /// + public string Reason { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/CountryOutput.cs b/src/Oms.Application/Dtos/System/CountryOutput.cs index 9d375a4..0129f30 100644 --- a/src/Oms.Application/Dtos/System/CountryOutput.cs +++ b/src/Oms.Application/Dtos/System/CountryOutput.cs @@ -24,4 +24,14 @@ public class CountryOutput /// ISO 3166-1 alpha-2 国家二字码。 /// public string CountryCode { get; set; } = string.Empty; + + /// + /// 创建时间。 + /// + public DateTime CreateTime { get; set; } + + /// + /// 更新时间;未更新时为空。 + /// + public DateTime? UpdateTime { get; set; } } diff --git a/src/Oms.Application/Dtos/System/CreateCountryInput.cs b/src/Oms.Application/Dtos/System/CreateCountryInput.cs new file mode 100644 index 0000000..1c7d1db --- /dev/null +++ b/src/Oms.Application/Dtos/System/CreateCountryInput.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 新增国家/地区入参。 +/// +public class CreateCountryInput +{ + /// + /// 国家中文名。 + /// + [Required] + public string ChineseName { get; set; } = string.Empty; + + /// + /// 国家英文名。 + /// + [Required] + public string EnglishName { get; set; } = string.Empty; + + /// + /// ISO 3166-1 alpha-2 国家二字码。 + /// + [Required] + public string CountryCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/UpdateCountryInput.cs b/src/Oms.Application/Dtos/System/UpdateCountryInput.cs new file mode 100644 index 0000000..9e13236 --- /dev/null +++ b/src/Oms.Application/Dtos/System/UpdateCountryInput.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 更新国家/地区入参。 +/// +public class UpdateCountryInput +{ + /// + /// 主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 国家中文名。 + /// + [Required] + public string ChineseName { get; set; } = string.Empty; + + /// + /// 国家英文名。 + /// + [Required] + public string EnglishName { get; set; } = string.Empty; + + /// + /// ISO 3166-1 alpha-2 国家二字码。 + /// + [Required] + public string CountryCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/System/CountryService.cs b/src/Oms.Application/Services/System/CountryService.cs index 51c93d6..77fa27b 100644 --- a/src/Oms.Application/Services/System/CountryService.cs +++ b/src/Oms.Application/Services/System/CountryService.cs @@ -1,6 +1,9 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.System; +using Oms.Application.Shared; +using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.System; @@ -16,20 +19,249 @@ public class CountryService : ApplicationService /// /// 查询国家列表。 /// - /// 国家列表,返回全部有效国家主数据。 + /// 国家列表,返回全部有效国家主数据,按二字码、英文名排序。 [HttpGet("/system/country/list")] public async Task> GetListAsync() { return (await CountryRepository.GetListAsync()) .OrderBy(x => x.CountryCode) .ThenBy(x => x.EnglishName) - .Select(x => new CountryOutput - { - Id = x.Id, - ChineseName = x.ChineseName, - EnglishName = x.EnglishName, - CountryCode = x.CountryCode - }) + .Select(ToOutput) .ToList(); } + + /// + /// 新增国家/地区。二字码已存在时失败;若仅存在同码软删除记录则恢复该记录。 + /// + /// 中文名、英文名、二字码。 + /// 无返回体,仅完成新增。 + [HttpPost("/system/country/create")] + public async Task CreateAsync(CreateCountryInput input) + { + var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode); + await SaveAsync(row, excludeId: null, updateWhenCodeExists: false); + } + + /// + /// 更新国家/地区。二字码与其他有效记录冲突时失败。 + /// + /// 主键及中文名、英文名、二字码。 + /// 无返回体,仅完成更新。 + [HttpPost("/system/country/update")] + public async Task UpdateAsync(UpdateCountryInput input) + { + var entity = await GetOrThrowAsync(input.Id); + var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode); + await SaveAsync(row, excludeId: entity.Id, updateWhenCodeExists: false); + } + + /// + /// 删除国家/地区(软删除)。 + /// + /// 目标主键。 + /// 无返回体,仅完成删除。 + [HttpPost("/system/country/delete")] + public async Task DeleteAsync(IdInput input) + { + var entity = await GetOrThrowAsync(input.Id); + await CountryRepository.DeleteAsync(entity); + } + + /// + /// 下载国家/地区导入模板。 + /// + /// xlsx 文件,列为国家二字码、中文名、英文名。 + [HttpGet("/system/country/import-template")] + public async Task DownloadImportTemplateAsync() + { + var bytes = await CountryImportHelper.BuildTemplateBytesAsync(); + return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = CountryImportHelper.TemplateFileName + }; + } + + /// + /// 导入国家/地区(Excel)。增量按二字码新增或更新;全量还会软删除文件中未出现的国家。 + /// + /// Excel 文件与导入模式。 + /// 成功、跳过、失败及全量删除条数。 + [HttpPost("/system/country/import")] + public async Task ImportAsync([FromForm] CountryImportInput input) + { + if (input.File == null || input.File.Length == 0) + { + throw new BusinessException("请上传 Excel 文件"); + } + + var importMode = NormalizeImportMode(input.ImportMode); + await using var stream = input.File.OpenReadStream(); + var parsedRows = await CountryImportHelper.ParseImportRowsAsync(stream); + if (parsedRows.Count == 0) + { + throw new BusinessException("Excel 中无有效数据行"); + } + + var output = new CountryImportOutput(); + var touchedCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (rowNumber, row) in parsedRows) + { + var rawCode = row.CountryCode?.Trim(); + if (!string.IsNullOrWhiteSpace(rawCode)) + { + touchedCodes.Add(rawCode.ToUpperInvariant()); + } + + try + { + CountryImportHelper.Normalize(row); + var code = row.CountryCode ?? string.Empty; + if (!seenCodes.Add(code)) + { + output.SkipCount++; + output.Failures.Add(new CountryImportFailRow + { + RowNumber = rowNumber, + Reason = "文件内二字码重复,已跳过" + }); + continue; + } + + await SaveAsync(row, excludeId: null, updateWhenCodeExists: true); + output.SuccessCount++; + } + catch (Exception ex) + { + output.FailCount++; + output.Failures.Add(new CountryImportFailRow + { + RowNumber = rowNumber, + Reason = ex.Message + }); + } + } + + if (importMode == CountryImportInput.ImportModes.Full && output.SuccessCount > 0) + { + var existing = await CountryRepository.GetListAsync(); + foreach (var entity in existing) + { + var code = entity.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty; + if (touchedCodes.Contains(code)) + { + continue; + } + + await CountryRepository.DeleteAsync(entity); + output.DeletedCount++; + } + } + + return output; + } + + private async Task SaveAsync(CountryImportRow row, int? excludeId, bool updateWhenCodeExists) + { + try + { + CountryImportHelper.Normalize(row); + } + catch (InvalidOperationException ex) + { + throw new BusinessException(ex.Message); + } + + var active = await CountryRepository.GetByCountryCodeAsync(row.CountryCode); + if (active != null && active.Id != excludeId) + { + if (updateWhenCodeExists) + { + ApplyNames(active, row); + await CountryRepository.UpdateAsync(active); + return; + } + + throw new BusinessException("国家二字码已存在"); + } + + var deleted = await CountryRepository.GetByCountryCodeIncludingDeletedAsync(row.CountryCode); + if (deleted != null && deleted.IsDeleted && deleted.Id != excludeId) + { + if (excludeId != null) + { + throw new BusinessException("国家二字码已被删除记录占用,请新增同码以恢复"); + } + + deleted.IsDeleted = false; + deleted.CountryCode = row.CountryCode; + ApplyNames(deleted, row); + await CountryRepository.UpdateAsync(deleted); + return; + } + + if (excludeId != null) + { + var entity = await GetOrThrowAsync(excludeId.Value); + entity.CountryCode = row.CountryCode; + ApplyNames(entity, row); + await CountryRepository.UpdateAsync(entity); + return; + } + + await CountryRepository.InsertAsync(new Country + { + CountryCode = row.CountryCode, + ChineseName = row.ChineseName, + EnglishName = row.EnglishName + }); + } + + private static void ApplyNames(Country entity, CountryImportRow row) + { + entity.ChineseName = row.ChineseName; + entity.EnglishName = row.EnglishName; + } + + private static CountryImportRow ToImportRow(string chineseName, string englishName, string countryCode) => + new() + { + ChineseName = chineseName, + EnglishName = englishName, + CountryCode = countryCode + }; + + private async Task GetOrThrowAsync(int id) + { + var entity = await CountryRepository.FirstOrDefaultAsync(x => x.Id == id); + if (entity == null) + { + throw new BusinessException("国家不存在"); + } + + return entity; + } + + private static CountryOutput ToOutput(Country x) => new() + { + Id = x.Id, + ChineseName = x.ChineseName, + EnglishName = x.EnglishName, + CountryCode = x.CountryCode, + CreateTime = x.CreateTime, + UpdateTime = x.UpdateTime == default ? null : x.UpdateTime + }; + + private static string NormalizeImportMode(string? mode) + { + if (string.Equals(mode, CountryImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase)) + { + return CountryImportInput.ImportModes.Full; + } + + return CountryImportInput.ImportModes.Incremental; + } } diff --git a/src/Oms.Application/Shared/CountryImportHelper.cs b/src/Oms.Application/Shared/CountryImportHelper.cs new file mode 100644 index 0000000..240f913 --- /dev/null +++ b/src/Oms.Application/Shared/CountryImportHelper.cs @@ -0,0 +1,179 @@ +using System.Text.RegularExpressions; +using MiniExcelLibs; + +namespace Oms.Application.Shared; + +/// +/// 国家/地区 Excel 模板与导入解析。 +/// +public static class CountryImportHelper +{ + public const string TemplateSheetName = "导入模板"; + public const string TemplateFileName = "国家地区导入模板.xlsx"; + + private static readonly Regex CountryCodePattern = new("^[A-Za-z]{2}$", RegexOptions.Compiled); + + private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["国家二字码"] = nameof(CountryImportRow.CountryCode), + ["二字码"] = nameof(CountryImportRow.CountryCode), + ["国家代码"] = nameof(CountryImportRow.CountryCode), + ["CountryCode"] = nameof(CountryImportRow.CountryCode), + ["countryCode"] = nameof(CountryImportRow.CountryCode), + ["中文名"] = nameof(CountryImportRow.ChineseName), + ["国家中文名"] = nameof(CountryImportRow.ChineseName), + ["ChineseName"] = nameof(CountryImportRow.ChineseName), + ["chineseName"] = nameof(CountryImportRow.ChineseName), + ["英文名"] = nameof(CountryImportRow.EnglishName), + ["国家英文名"] = nameof(CountryImportRow.EnglishName), + ["EnglishName"] = nameof(CountryImportRow.EnglishName), + ["englishName"] = nameof(CountryImportRow.EnglishName), + }; + + public static async Task BuildTemplateBytesAsync() + { + var templateRows = new List> + { + new() + { + ["国家二字码"] = "CN", + ["中文名"] = "中国", + ["英文名"] = "China" + }, + new() + { + ["国家二字码"] = "US", + ["中文名"] = "美国", + ["英文名"] = "United States" + } + }; + + var sheets = new Dictionary + { + [TemplateSheetName] = templateRows + }; + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, sheets); + return stream.ToArray(); + } + + public static async Task> ParseImportRowsAsync(Stream fileStream) + { + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var sheetNames = MiniExcel.GetSheetNames(fileStream); + var sheetName = sheetNames.FirstOrDefault(x => + string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase)) + ?? sheetNames.FirstOrDefault(); + + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName); + var result = new List<(int RowNumber, CountryImportRow Row)>(); + var rowNumber = 1; + + foreach (var row in rows) + { + rowNumber++; + if (row is not IDictionary rowDict || IsEmptyRow(rowDict)) + { + continue; + } + + result.Add((rowNumber, ParseRow(rowDict))); + } + + return result; + } + + public static void Normalize(CountryImportRow row) + { + row.CountryCode = row.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty; + row.ChineseName = row.ChineseName?.Trim() ?? string.Empty; + row.EnglishName = row.EnglishName?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(row.CountryCode)) + { + throw new InvalidOperationException("国家二字码不能为空"); + } + + if (!CountryCodePattern.IsMatch(row.CountryCode)) + { + throw new InvalidOperationException("国家二字码须为 2 位字母,如 CN、US"); + } + + if (string.IsNullOrWhiteSpace(row.ChineseName)) + { + throw new InvalidOperationException("中文名不能为空"); + } + + if (row.ChineseName.Length > 128) + { + throw new InvalidOperationException("中文名不可超过 128 个字符"); + } + + if (string.IsNullOrWhiteSpace(row.EnglishName)) + { + throw new InvalidOperationException("英文名不能为空"); + } + + if (row.EnglishName.Length > 128) + { + throw new InvalidOperationException("英文名不可超过 128 个字符"); + } + } + + private static CountryImportRow ParseRow(IDictionary rowDict) + { + var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (header, value) in rowDict) + { + if (string.IsNullOrWhiteSpace(header) || !HeaderAliases.TryGetValue(header.Trim(), out var field)) + { + continue; + } + + mapped[field] = NormalizeCell(value); + } + + return new CountryImportRow + { + CountryCode = mapped.GetValueOrDefault(nameof(CountryImportRow.CountryCode)) ?? string.Empty, + ChineseName = mapped.GetValueOrDefault(nameof(CountryImportRow.ChineseName)) ?? string.Empty, + EnglishName = mapped.GetValueOrDefault(nameof(CountryImportRow.EnglishName)) ?? string.Empty + }; + } + + private static string? NormalizeCell(object? value) + { + if (value is null) + { + return null; + } + + var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static bool IsEmptyRow(IDictionary rowDict) => + rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value))); +} + +/// +/// 国家导入行。 +/// +public class CountryImportRow +{ + public string CountryCode { get; set; } = string.Empty; + + public string ChineseName { get; set; } = string.Empty; + + public string EnglishName { get; set; } = string.Empty; +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs index 3ca1add..8f5367e 100644 --- a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs +++ b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs @@ -6,4 +6,9 @@ namespace Oms.Domain.SystemAggregate.Repositories; public interface ICountryRepository : IRepository { Task GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default); + + /// + /// 按二字码查询,包含已软删除记录,用于恢复同码数据。 + /// + Task GetByCountryCodeIncludingDeletedAsync(string countryCode, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/System/CountryRepository.cs b/src/Oms.SqlSugarCore/System/CountryRepository.cs index 988c6ac..8a7c2f7 100644 --- a/src/Oms.SqlSugarCore/System/CountryRepository.cs +++ b/src/Oms.SqlSugarCore/System/CountryRepository.cs @@ -11,4 +11,15 @@ public class CountryRepository : OmsSqlSugarRepository, ICountryReposit { return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken); } + + public async Task GetByCountryCodeIncludingDeletedAsync( + string countryCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.CountryCode == countryCode, + cancellationToken); + } } From a1a7524a0ed7578f145ddcefd3e69704ec8c663c Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 16:15:51 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20TikTok=20=E9=87=8D=E6=96=B0=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E9=9D=A2=E5=8D=95=E6=94=B9=E4=B8=BA=E6=8C=89=E5=8C=85?= =?UTF-8?q?=E8=A3=B9=E4=B8=8B=E8=BD=BD=E5=8F=91=E8=B4=A7=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../PlatformShopFulfillmentClient.cs | 159 +++++++++++++++++- 1 file changed, 151 insertions(+), 8 deletions(-) diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index 488d6a3..ce0e09b 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -1,7 +1,9 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; +using Fake.ObjectStorage; using Microsoft.Extensions.Logging; +using Oms.Application.Storage; using Oms.Domain.OrdersAggregate.Entities; namespace Oms.Application.Integrations.PlatformShop; @@ -141,6 +143,7 @@ public sealed class PlatformShopLabelResult internal sealed class PlatformShopFulfillmentClient( IHttpClientFactory httpClientFactory, IPlatformShopConfigProvider configProvider, + IObjectStorage objectStorage, ILogger logger) : IPlatformShopFulfillmentClient { private const string LabelPendingText = "系统获取中"; @@ -853,21 +856,161 @@ internal sealed class PlatformShopFulfillmentClient( }; } - private Task LabelTiktokAsync( + /// + /// 对齐 WMS ShopordTiktokImpl.retryOrderSheet:订单详情取 package_id,再取 SHIPPING_LABEL 并落库。 + /// + private async Task LabelTiktokAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { - var labelNo = $"TT-{platformOrderNo}"; - var labelUrl = $"https://seller.tiktokglobalshop.com/order/label/{platformOrderNo}"; - return Task.FromResult(new PlatformShopLabelResult + EnsureTiktok(store, options); + var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); + if (string.IsNullOrWhiteSpace(shopCipher)) + { + return new PlatformShopLabelResult { Success = false, Message = "未获取到 TikTok shop_cipher,请重新授权店铺" }; + } + + var tiktok = options.Tiktok; + using var detail = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["ids"] = platformOrderNo, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, ct); + if (!TryGetTiktokData(detail.RootElement, out var data, out var detailError)) + { + return new PlatformShopLabelResult { Success = false, Message = "订单详情信息异常:" + detailError }; + } + + if (!data.TryGetProperty("orders", out var orders) + || orders.ValueKind != JsonValueKind.Array + || orders.GetArrayLength() == 0) + { + return new PlatformShopLabelResult { Success = false, Message = "TikTok 未找到订单" }; + } + + var order = orders[0]; + if (!order.TryGetProperty("line_items", out var lines) + || lines.ValueKind != JsonValueKind.Array + || lines.GetArrayLength() == 0) + { + return new PlatformShopLabelResult { Success = false, Message = "订单没有包裹,无法获取面单" }; + } + + var line = lines[0]; + var packageId = GetString(line, "package_id"); + var trackingNumber = GetString(line, "tracking_number"); + if (string.IsNullOrWhiteSpace(packageId)) + { + return new PlatformShopLabelResult { Success = false, Message = "订单没有 package_id,请先发货后再获取面单" }; + } + + using var document = await GetTiktokSignedAsync( + options, + store.AccessToken!, + $"/fulfillment/202309/packages/{packageId}/shipping_documents", + new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["document_type"] = "SHIPPING_LABEL", + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, + ct); + if (!TryGetTiktokData(document.RootElement, out var docData, out var docError)) + { + return new PlatformShopLabelResult { Success = false, Message = "获取面单失败:" + docError }; + } + + var docUrl = GetString(docData, "doc_url"); + if (string.IsNullOrWhiteSpace(docUrl) || !docUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return new PlatformShopLabelResult + { + Success = false, + Message = string.IsNullOrWhiteSpace(docUrl) ? "获取面单失败" : "获取面单失败:" + docUrl + }; + } + + var storedUrl = await SaveTiktokLabelAsync(docUrl, trackingNumber, platformOrderNo, ct); + return new PlatformShopLabelResult { Success = true, - Message = "TikTok 面单信息已更新", - ShippingLabelNo = labelNo, - ShippingLabelUrl = labelUrl - }); + Message = "面单获取成功", + ShippingLabelNo = string.IsNullOrWhiteSpace(trackingNumber) ? null : trackingNumber, + ShippingLabelUrl = storedUrl + }; + } + + /// + /// 下载 TikTok 面单 PDF 并写入对象存储。下载失败时退回平台临时地址。 + /// + private async Task SaveTiktokLabelAsync( + string docUrl, + string? trackingNumber, + string platformOrderNo, + CancellationToken ct) + { + try + { + var client = httpClientFactory.CreateClient(PlatformShopApiClient.HttpClientName); + var bytes = await client.GetByteArrayAsync(docUrl, ct); + if (bytes.Length == 0) + { + return docUrl; + } + + var name = string.IsNullOrWhiteSpace(trackingNumber) ? platformOrderNo : trackingNumber; + var key = $"uploads/platform-label/{DateTime.Now:yyyyMM}/md_{name}_{Guid.NewGuid():N}.pdf"; + await using var stream = new MemoryStream(bytes); + var objectKey = await objectStorage.SaveObjectAsync(stream, key, "application/pdf", ct); + var publicUrl = await objectStorage.GetPublicUrlAsync(objectKey, ct); + return publicUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? publicUrl : docUrl; + } + catch (Exception ex) + { + logger.LogWarning(ex, "TikTok 面单下载落库失败,改用平台地址"); + return docUrl; + } + } + + private async Task GetTiktokSignedAsync( + PlatformShopOptions options, + string accessToken, + string path, + Dictionary query, + CancellationToken ct) + { + var sign = PlatformShopCrypto.TiktokSign(path, query, null, options.Tiktok.AppSecret); + var url = $"{options.Tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}"; + return await GetJsonAsync(url, ct, accessToken); + } + + private static bool TryGetTiktokData(JsonElement root, out JsonElement data, out string error) + { + data = default; + error = "返回为空"; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + + var code = GetString(root, "code"); + error = GetString(root, "message") ?? GetString(root, "msg") ?? error; + if (code is not ("0" or null)) + { + return false; + } + + if (!root.TryGetProperty("data", out data) || data.ValueKind != JsonValueKind.Object) + { + return false; + } + + return true; } private static string MapTiktokFulfill(string? status) => status?.ToUpperInvariant() switch From 9c97a218ecc02766377eeb74f99c8cc15dbb8f15 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 16:59:28 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20TikTok=20=E6=8B=89=E5=8D=95=E6=8C=89?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E8=AF=A6=E6=83=85=E6=98=A0=E5=B0=84=E6=94=B6?= =?UTF-8?q?=E4=BB=B6=E4=BA=BA=E9=87=91=E9=A2=9D=E8=A1=8C=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=B9=B6=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../PlatformShopFulfillmentClient.cs | 362 ++++++++++++++++-- .../PlatformStoreOrderFulfillmentService.cs | 31 +- .../Orders/PlatformStoreOrderParcelBuilder.cs | 75 +++- 3 files changed, 429 insertions(+), 39 deletions(-) diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index ce0e09b..e73b5bd 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -29,6 +29,14 @@ public interface IPlatformShopFulfillmentClient SalesStore store, string platformOrderNo, CancellationToken ct = default); + + /// + /// 拉取平台订单完整明细并映射为落库结构;不支持时返回 null。 + /// + Task FetchOrderDetailAsync( + SalesStore store, + string platformOrderNo, + CancellationToken ct = default); } public sealed class PlatformShopPullOrdersRequest @@ -55,6 +63,24 @@ public sealed class PlatformShopPulledOrder public string? OrderCountry { get; set; } + public string? RecipientName { get; set; } + + public string? RecipientPhone { get; set; } + + public string? RecipientEmail { get; set; } + + public string? RecipientCountry { get; set; } + + public string? RecipientProvince { get; set; } + + public string? RecipientCity { get; set; } + + public string? RecipientZipCode { get; set; } + + public string? RecipientAddress1 { get; set; } + + public string? RecipientAddress2 { get; set; } + public int ItemCount { get; set; } public decimal? TotalAmount { get; set; } @@ -73,6 +99,8 @@ public sealed class PlatformShopPulledOrder public string? ShippingLabelUrl { get; set; } + public bool IsMultiLabel { get; set; } + public string? ExtraJson { get; set; } public List Lines { get; set; } = []; @@ -86,6 +114,10 @@ public sealed class PlatformShopPulledOrderLine public string? ProductName { get; set; } + public string? ProductImageUrl { get; set; } + + public string? VariantText { get; set; } + public int Quantity { get; set; } public decimal? UnitPrice { get; set; } @@ -212,6 +244,19 @@ internal sealed class PlatformShopFulfillmentClient( }; } + public async Task FetchOrderDetailAsync( + SalesStore store, + string platformOrderNo, + CancellationToken ct = default) + { + var options = await configProvider.GetAsync(ct); + return store.PlatformCode.ToLowerInvariant() switch + { + "tiktok" => await FetchTiktokOrderDetailAsync(store, options, platformOrderNo, ct), + _ => null + }; + } + #region Shopee private async Task PullShopeeAsync( @@ -628,14 +673,8 @@ internal sealed class PlatformShopFulfillmentClient( { var id = GetString(item, "id"); if (string.IsNullOrWhiteSpace(id) || orders.ContainsKey(id)) continue; - var status = GetString(item, "status") ?? statusFilter; - orders[id] = new PlatformShopPulledOrder - { - PlatformOrderNo = id, - PlatformStatus = status, - PlatformStatusLabel = status, - FulfillStatus = MapTiktokFulfill(status) - }; + // 搜索结果可能已带明细;没有 line_items 时后面再按详情接口补齐 + orders[id] = MapTiktokOrder(item, statusFilter); } } @@ -656,6 +695,8 @@ internal sealed class PlatformShopFulfillmentClient( }; } + await EnrichTiktokOrderDetailsAsync(store, options, shopCipher, orders, errors, ct); + var messageText = $"TikTok 拉单完成,处理 {orders.Count} 单"; if (errors.Count > 0) { @@ -670,6 +711,186 @@ internal sealed class PlatformShopFulfillmentClient( }; } + /// + /// 按订单 ID 批量拉详情(对齐 WMS getOrderDetail),补齐收件人、金额、行商品、运单号。 + /// + private async Task EnrichTiktokOrderDetailsAsync( + SalesStore store, + PlatformShopOptions options, + string shopCipher, + Dictionary orders, + List errors, + CancellationToken ct) + { + var needDetail = orders.Values + .Where(x => x.Lines.Count == 0 + || string.IsNullOrWhiteSpace(x.RecipientAddress1) + || !x.TotalAmount.HasValue) + .Select(x => x.PlatformOrderNo) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (needDetail.Count == 0) + { + return; + } + + var tiktok = options.Tiktok; + for (var i = 0; i < needDetail.Count; i += 50) + { + var chunk = needDetail.Skip(i).Take(50).ToList(); + using var doc = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["ids"] = string.Join(",", chunk), + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, ct); + if (!TryGetTiktokData(doc.RootElement, out var data, out var detailError) + || !data.TryGetProperty("orders", out var arr) + || arr.ValueKind != JsonValueKind.Array) + { + errors.Add("订单详情失败:" + detailError); + continue; + } + + foreach (var item in arr.EnumerateArray()) + { + var mapped = MapTiktokOrder(item); + if (string.IsNullOrWhiteSpace(mapped.PlatformOrderNo)) continue; + orders[mapped.PlatformOrderNo] = mapped; + } + } + } + + /// + /// 按 TikTok 订单明细 JSON 取值,字段对齐店铺订单主表/明细表。 + /// + private static PlatformShopPulledOrder MapTiktokOrder(JsonElement order, string? fallbackStatus = null) + { + var id = GetString(order, "id") ?? string.Empty; + var status = GetString(order, "status") ?? fallbackStatus ?? string.Empty; + var recipient = order.TryGetProperty("recipient_address", out var addr) && addr.ValueKind == JsonValueKind.Object + ? addr + : default; + var payment = order.TryGetProperty("payment", out var pay) && pay.ValueKind == JsonValueKind.Object + ? pay + : default; + + var (province, city, countryFromDistrict) = ParseTiktokDistrict(recipient); + var country = FirstNonEmpty( + GetString(recipient, "region_code"), + countryFromDistrict, + GetString(recipient, "region")); + + var lines = new List(); + var packageIds = new HashSet(StringComparer.Ordinal); + if (order.TryGetProperty("line_items", out var lineItems) && lineItems.ValueKind == JsonValueKind.Array) + { + foreach (var line in lineItems.EnumerateArray()) + { + var packageId = GetString(line, "package_id"); + if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId); + lines.Add(new PlatformShopPulledOrderLine + { + PlatformLineId = GetString(line, "id"), + PlatformSku = GetString(line, "seller_sku"), + ProductName = GetString(line, "product_name"), + ProductImageUrl = GetString(line, "sku_image"), + VariantText = GetString(line, "sku_name"), + // TikTok 一行一件,没有 quantity 字段 + Quantity = 1, + UnitPrice = GetDecimal(line, "sale_price") ?? GetDecimal(line, "original_price"), + Currency = GetString(line, "currency") + }); + } + } + + if (order.TryGetProperty("packages", out var packages) && packages.ValueKind == JsonValueKind.Array) + { + foreach (var pkg in packages.EnumerateArray()) + { + var packageId = GetString(pkg, "id"); + if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId); + } + } + + var tracking = GetString(order, "tracking_number"); + if (string.IsNullOrWhiteSpace(tracking) + && order.TryGetProperty("line_items", out var itemsForTrack) + && itemsForTrack.ValueKind == JsonValueKind.Array + && itemsForTrack.GetArrayLength() > 0) + { + tracking = GetString(itemsForTrack[0], "tracking_number"); + } + + return new PlatformShopPulledOrder + { + PlatformOrderNo = id, + OrderCountry = country, + RecipientName = FirstNonEmpty(GetString(recipient, "name"), GetString(recipient, "full_address")), + RecipientPhone = GetString(recipient, "phone_number"), + RecipientEmail = GetString(order, "buyer_email"), + RecipientCountry = country, + RecipientProvince = province, + RecipientCity = city, + RecipientZipCode = GetString(recipient, "postal_code"), + RecipientAddress1 = FirstNonEmpty( + GetString(recipient, "address_line1"), + GetString(recipient, "address_detail"), + GetString(recipient, "full_address")), + RecipientAddress2 = GetString(recipient, "address_line2"), + ItemCount = lines.Count, + TotalAmount = GetDecimal(payment, "total_amount") ?? GetDecimal(payment, "sub_total"), + Currency = FirstNonEmpty(GetString(payment, "currency"), lines.FirstOrDefault()?.Currency), + PlatformOrderTime = FromUnixSeconds(GetLong(order, "create_time")), + PlatformStatus = status, + PlatformStatusLabel = status, + FulfillStatus = MapTiktokFulfill(status), + ShippingLabelNo = tracking, + IsMultiLabel = packageIds.Count > 1, + ExtraJson = order.ValueKind == JsonValueKind.Undefined ? null : order.GetRawText(), + Lines = lines + }; + } + + private static (string? Province, string? City, string? CountryCode) ParseTiktokDistrict(JsonElement recipient) + { + string? province = null; + string? city = null; + string? country = null; + if (recipient.ValueKind != JsonValueKind.Object + || !recipient.TryGetProperty("district_info", out var districts) + || districts.ValueKind != JsonValueKind.Array) + { + return (null, null, null); + } + + foreach (var district in districts.EnumerateArray()) + { + var level = GetString(district, "address_level"); + var name = GetString(district, "address_name"); + var iso = GetString(district, "iso_code"); + switch (level) + { + case "L0": + country = FirstNonEmpty(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); + } + /// /// 从 Get Authorized Shops 取当前店的 shop_cipher。WMS 授权时落库,拉单查询必须带上。 /// @@ -791,34 +1012,57 @@ internal sealed class PlatformShopFulfillmentClient( return normalized.Count > 0 ? normalized : [.. TiktokPullStatuses]; } - private async Task SyncTiktokAsync( + private async Task FetchTiktokOrderDetailAsync( 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) + var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); + if (string.IsNullOrWhiteSpace(shopCipher)) { - return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单" }; + return null; + } + + using var doc = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary + { + ["app_key"] = options.Tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["ids"] = platformOrderNo, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, ct); + if (!TryGetTiktokData(doc.RootElement, out var data, out _) + || !data.TryGetProperty("orders", out var orders) + || orders.ValueKind != JsonValueKind.Array + || orders.GetArrayLength() == 0) + { + return null; + } + + return MapTiktokOrder(orders[0]); + } + + private async Task SyncTiktokAsync( + SalesStore store, + PlatformShopOptions options, + string platformOrderNo, + CancellationToken ct) + { + var mapped = await FetchTiktokOrderDetailAsync(store, options, platformOrderNo, ct); + if (mapped == null) + { + return new PlatformShopOrderStatusResult { Success = false, Message = "TikTok 未找到订单或缺少 shop_cipher" }; } - var order = orders[0]; - var status = GetString(order, "status") ?? string.Empty; return new PlatformShopOrderStatusResult { Success = true, Message = "同步成功", - PlatformStatus = status, - PlatformStatusLabel = status, - FulfillStatus = MapTiktokFulfill(status) + PlatformStatus = mapped.PlatformStatus, + PlatformStatusLabel = mapped.PlatformStatusLabel, + FulfillStatus = mapped.FulfillStatus, + ShippingLabelNo = mapped.ShippingLabelNo }; } @@ -1266,11 +1510,73 @@ internal sealed class PlatformShopFulfillmentClient( } } - 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; + private static string? GetString(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p)) + { + return null; + } + + return p.ValueKind switch + { + JsonValueKind.String => p.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => p.ToString(), + _ => null + }; + } + + private static decimal? GetDecimal(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p)) + { + return null; + } + + if (p.ValueKind == JsonValueKind.Number && p.TryGetDecimal(out var number)) + { + return number; + } + + if (p.ValueKind == JsonValueKind.String + && decimal.TryParse(p.GetString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + + return null; + } + + private static long? GetLong(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var p)) + { + return null; + } + + if (p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var number)) + { + return number; + } + + if (p.ValueKind == JsonValueKind.String + && long.TryParse(p.GetString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + + return null; + } + + private static DateTime? FromUnixSeconds(long? unix) + { + if (unix is null or <= 0) return null; + return DateTimeOffset.FromUnixTimeSeconds(unix.Value).LocalDateTime; + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); #endregion } diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs index e8e06de..7f9bc3a 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs @@ -187,6 +187,23 @@ internal sealed class PlatformStoreOrderFulfillmentService( try { + var detail = await fulfillmentClient.FetchOrderDetailAsync(store, order.PlatformOrderNo); + if (detail != null) + { + await UpsertPulledOrderAsync(order.CustomerId, store, detail); + var refreshed = await storeOrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order; + return new PlatformStoreOrderBatchActionItemOutput + { + Id = refreshed.Id, + PlatformOrderNo = refreshed.PlatformOrderNo, + Success = true, + Message = "同步成功", + FulfillStatus = refreshed.FulfillStatus, + ShippingLabelNo = refreshed.ShippingLabelNo, + ShippingLabelUrl = refreshed.ShippingLabelUrl + }; + } + var result = await fulfillmentClient.FetchOrderStatusAsync(store, order.PlatformOrderNo); if (!result.Success) { @@ -307,6 +324,15 @@ internal sealed class PlatformStoreOrderFulfillmentService( } entity.OrderCountry = pulled.OrderCountry ?? entity.OrderCountry; + if (!string.IsNullOrWhiteSpace(pulled.RecipientName)) entity.RecipientName = pulled.RecipientName; + if (!string.IsNullOrWhiteSpace(pulled.RecipientPhone)) entity.RecipientPhone = pulled.RecipientPhone; + if (!string.IsNullOrWhiteSpace(pulled.RecipientEmail)) entity.RecipientEmail = pulled.RecipientEmail; + if (!string.IsNullOrWhiteSpace(pulled.RecipientCountry)) entity.RecipientCountry = pulled.RecipientCountry; + if (!string.IsNullOrWhiteSpace(pulled.RecipientProvince)) entity.RecipientProvince = pulled.RecipientProvince; + if (!string.IsNullOrWhiteSpace(pulled.RecipientCity)) entity.RecipientCity = pulled.RecipientCity; + if (!string.IsNullOrWhiteSpace(pulled.RecipientZipCode)) entity.RecipientZipCode = pulled.RecipientZipCode; + if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress1)) entity.RecipientAddress1 = pulled.RecipientAddress1; + if (!string.IsNullOrWhiteSpace(pulled.RecipientAddress2)) entity.RecipientAddress2 = pulled.RecipientAddress2; entity.ItemCount = pulled.ItemCount > 0 ? pulled.ItemCount : entity.ItemCount; entity.TotalAmount = pulled.TotalAmount ?? entity.TotalAmount; entity.Currency = pulled.Currency ?? entity.Currency; @@ -316,6 +342,7 @@ internal sealed class PlatformStoreOrderFulfillmentService( entity.FulfillStatus = pulled.FulfillStatus; entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo; entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl; + entity.IsMultiLabel = pulled.IsMultiLabel || entity.IsMultiLabel; entity.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson; entity.LastSyncTime = now; PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson); @@ -348,7 +375,9 @@ internal sealed class PlatformStoreOrderFulfillmentService( PlatformLineId = line.PlatformLineId, PlatformSku = line.PlatformSku, ProductName = line.ProductName, - Quantity = line.Quantity, + ProductImageUrl = line.ProductImageUrl, + VariantText = line.VariantText, + Quantity = line.Quantity > 0 ? line.Quantity : 1, UnitPrice = line.UnitPrice, Currency = line.Currency }); diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs index c4828d8..ae10249 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs @@ -306,7 +306,10 @@ public static class PlatformStoreOrderParcelBuilder private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node) { var candidates = new List { node }; - foreach (var key in new[] { "recipient", "receiver", "shippingAddress", "address" }) + foreach (var key in new[] + { + "recipient_address", "recipientAddress", "recipient", "receiver", "shippingAddress", "address" + }) { if (node[key] != null) { @@ -321,10 +324,22 @@ public static class PlatformStoreOrderParcelBuilder continue; } - var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName"); - var country = ReadText(candidate, "countryCode", "country", "region", "orderCountry") ?? ReadText(node, "orderCountry"); - var zip = ReadText(candidate, "zipCode", "zip", "postalCode", "postcode"); - var address = ReadText(candidate, "streetAddress1", "address", "address1", "fullAddress", "detailAddress"); + 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; @@ -334,14 +349,14 @@ public static class PlatformStoreOrderParcelBuilder { RecipientsName = name, CompanyName = ReadText(candidate, "companyName", "company"), - Phone = ReadText(candidate, "phone", "mobile", "phoneNumber"), + Phone = ReadText(candidate, "phone", "mobile", "phone_number", "phoneNumber"), TelePhone = ReadText(candidate, "telePhone", "tel"), - Email = ReadText(candidate, "email"), + Email = ReadText(candidate, "email") ?? ReadText(node, "buyer_email"), CountryCode = country, - Province = ReadText(candidate, "province", "state", "regionState"), - City = ReadText(candidate, "city"), + Province = ReadText(candidate, "province", "state", "regionState") ?? province, + City = ReadText(candidate, "city") ?? city, StreetAddress1 = address, - StreetAddress2 = ReadText(candidate, "streetAddress2", "address2"), + StreetAddress2 = ReadText(candidate, "streetAddress2", "address_line2", "address2"), ZipCode = zip }; } @@ -349,6 +364,46 @@ public static class PlatformStoreOrderParcelBuilder return null; } + /// + /// 解析 TikTok recipient_address.district_info:L0 国家、L1 州省、L3 城市。 + /// + 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) From 7a611f8cf73e976491f146e9b76ecbf7c4bac447 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 17:31:19 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20TikTok=20=E5=87=86=E5=A4=87=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E6=94=B9=E4=B8=BA=E7=9C=9F=E5=AE=9E=20shipPackage?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=9B=9E=E5=86=99=E9=9D=A2=E5=8D=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../PlatformShopFulfillmentClient.cs | 156 +++++++++++++++++- .../PlatformStoreOrderFulfillmentService.cs | 35 ++++ 2 files changed, 182 insertions(+), 9 deletions(-) diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index e73b5bd..9617dcd 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -1066,40 +1066,178 @@ internal sealed class PlatformShopFulfillmentClient( }; } + /// + /// 对齐 WMS ShopordTiktokImpl.send:订单详情取 package_id → 校验 DropOff → shipPackage,再尝试拉面单。 + /// private async Task RtsTiktokAsync( SalesStore store, PlatformShopOptions options, string platformOrderNo, CancellationToken ct) { - var sync = await SyncTiktokAsync(store, options, platformOrderNo, ct); - if (!sync.Success) + EnsureTiktok(store, options); + var shopCipher = await GetTiktokShopCipherAsync(store, options, ct); + if (string.IsNullOrWhiteSpace(shopCipher)) { - return new PlatformShopReadyToShipResult { Success = false, Message = sync.Message }; + return new PlatformShopReadyToShipResult { Success = false, Message = "未获取到 TikTok shop_cipher,请重新授权店铺" }; } - if (!string.Equals(sync.PlatformStatus, "AWAITING_SHIPMENT", StringComparison.OrdinalIgnoreCase) - && sync.PlatformStatus != "111") + var tiktok = options.Tiktok; + using var detail = await GetTiktokSignedAsync(options, store.AccessToken!, "/order/202309/orders", new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["ids"] = platformOrderNo, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, ct); + if (!TryGetTiktokData(detail.RootElement, out var data, out var detailError)) + { + return new PlatformShopReadyToShipResult { Success = false, Message = "订单详情信息异常:" + detailError }; + } + + if (!data.TryGetProperty("orders", out var orders) + || orders.ValueKind != JsonValueKind.Array + || orders.GetArrayLength() == 0) + { + return new PlatformShopReadyToShipResult { Success = false, Message = "TikTok 未找到订单" }; + } + + var order = orders[0]; + var status = GetString(order, "status") ?? string.Empty; + if (string.Equals(status, "CANCELLED", StringComparison.OrdinalIgnoreCase)) + { + return new PlatformShopReadyToShipResult { Success = false, Message = "订单已取消,不可准备发货" }; + } + + if (status is "IN_TRANSIT" or "DELIVERED" or "COMPLETED" + || string.Equals(status, "AWAITING_COLLECTION", StringComparison.OrdinalIgnoreCase)) { return new PlatformShopReadyToShipResult { Success = false, - Message = $"TikTok 订单状态为 {sync.PlatformStatus},不可 RTS" + Message = $"TikTok 订单状态为 {status},不可准备发货" }; } + if (!order.TryGetProperty("line_items", out var lineItems) + || lineItems.ValueKind != JsonValueKind.Array + || lineItems.GetArrayLength() == 0) + { + return new PlatformShopReadyToShipResult { Success = false, Message = "商品信息为空" }; + } + + var packageIds = new List(); + foreach (var line in lineItems.EnumerateArray()) + { + var packageId = GetString(line, "package_id"); + if (string.IsNullOrWhiteSpace(packageId)) continue; + if (!packageIds.Contains(packageId, StringComparer.Ordinal)) + { + packageIds.Add(packageId); + } + } + + if (packageIds.Count == 0) + { + return new PlatformShopReadyToShipResult { Success = false, Message = "订单没有 package_id,无法准备发货" }; + } + + foreach (var packageId in packageIds) + { + using var handoverDoc = await GetTiktokSignedAsync( + options, + store.AccessToken!, + $"/fulfillment/202309/packages/{packageId}/handover_time_slots", + new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }, + ct); + if (!TryGetTiktokData(handoverDoc.RootElement, out var handoverData, out var handoverError)) + { + return new PlatformShopReadyToShipResult + { + Success = false, + Message = "包裹支持发货类型获异常:" + handoverError + }; + } + + var canDropOff = handoverData.TryGetProperty("can_drop_off", out var drop) + && drop.ValueKind is JsonValueKind.True or JsonValueKind.False + && drop.GetBoolean(); + if (!canDropOff) + { + return new PlatformShopReadyToShipResult { Success = false, Message = "包裹不支持 DropOff 发货" }; + } + + var shipPath = $"/fulfillment/202309/packages/{packageId}/ship"; + var shipQuery = new Dictionary + { + ["app_key"] = tiktok.AppKey, + ["shop_cipher"] = shopCipher, + ["timestamp"] = PlatformShopCrypto.UnixSeconds().ToString() + }; + var shipBody = new Dictionary { ["handover_method"] = "DROP_OFF" }; + using var shipDoc = await PostTiktokSignedAsync(options, store.AccessToken!, shipPath, shipQuery, shipBody, ct); + // ship 成功时 data 可能为 null,只认 code=0 + var shipCode = GetString(shipDoc.RootElement, "code"); + if (shipCode is not ("0" or null)) + { + var msg = GetString(shipDoc.RootElement, "message") ?? "发货失败"; + return new PlatformShopReadyToShipResult { Success = false, Message = "发货异常:" + msg }; + } + } + + string? labelNo = null; + string? labelUrl = LabelPendingText; + var labelPending = true; + try + { + var label = await LabelTiktokAsync(store, options, platformOrderNo, ct); + if (label.Success) + { + labelNo = label.ShippingLabelNo; + if (!string.IsNullOrWhiteSpace(label.ShippingLabelUrl)) + { + labelUrl = label.ShippingLabelUrl; + labelPending = false; + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "TikTok 准备发货后拉面单失败:{OrderNo}", platformOrderNo); + } + return new PlatformShopReadyToShipResult { Success = true, - Message = "TikTok RTS 已提交,面单获取中", + Message = labelPending ? "准备发货成功,面单获取中" : "准备发货成功", PlatformStatus = "AWAITING_COLLECTION", PlatformStatusLabel = "AWAITING_COLLECTION", FulfillStatus = "toShip", - ShippingLabelUrl = LabelPendingText, - LabelPending = true + ShippingLabelNo = labelNo, + ShippingLabelUrl = labelUrl, + LabelPending = labelPending }; } + private async Task PostTiktokSignedAsync( + PlatformShopOptions options, + string accessToken, + string path, + Dictionary query, + object body, + CancellationToken ct) + { + var bodyJson = JsonSerializer.Serialize(body); + var sign = PlatformShopCrypto.TiktokSign(path, query, bodyJson, options.Tiktok.AppSecret); + var url = $"{options.Tiktok.HostApi}{path}?{BuildTiktokQuery(query, sign)}"; + return await PostRawJsonAsync(url, bodyJson, ct, accessToken); + } + /// /// 对齐 WMS ShopordTiktokImpl.retryOrderSheet:订单详情取 package_id,再取 SHIPPING_LABEL 并落库。 /// diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs index 7f9bc3a..6b4a8dd 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs @@ -280,6 +280,11 @@ internal sealed class PlatformStoreOrderFulfillmentService( { order.ShippingLabelNo = result.ShippingLabelNo; } + else if (result.LabelPending) + { + // 对齐 WMS:准备发货后先标记面单待获取 + order.ShippingLabelNo = "系统获取中"; + } if (!string.IsNullOrWhiteSpace(result.ShippingLabelUrl)) { @@ -288,6 +293,36 @@ internal sealed class PlatformStoreOrderFulfillmentService( order.LastSyncTime = DateTime.Now; await storeOrderRepository.UpdateAsync(order); + + // TikTok:发货后再拉详情,回写运单号等;面单未就绪时保留「系统获取中」 + if (string.Equals(order.PlatformCode, "tiktok", StringComparison.OrdinalIgnoreCase)) + { + try + { + var detail = await fulfillmentClient.FetchOrderDetailAsync(store, order.PlatformOrderNo); + if (detail != null) + { + if (result.LabelPending && string.IsNullOrWhiteSpace(detail.ShippingLabelUrl)) + { + detail.ShippingLabelUrl = "系统获取中"; + detail.ShippingLabelNo ??= "系统获取中"; + } + + detail.PlatformStatus ??= "AWAITING_COLLECTION"; + detail.PlatformStatusLabel ??= detail.PlatformStatus; + detail.FulfillStatus = string.IsNullOrWhiteSpace(detail.FulfillStatus) + ? "toShip" + : detail.FulfillStatus; + await UpsertPulledOrderAsync(order.CustomerId, store, detail); + order = await storeOrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order; + } + } + catch + { + // 详情回写失败不影响 RTS 成功结果 + } + } + return new PlatformStoreOrderBatchActionItemOutput { Id = order.Id, From 983dff12c571bdf5d30134d8e4c33ec4cd3644a8 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 18:01:46 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E5=BA=97=E9=93=BA=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=90=BD=E5=BA=93=E7=89=A9=E6=B5=81=E5=95=86=E4=B8=8E?= =?UTF-8?q?=E5=B1=A5=E7=BA=A6=E7=B1=BB=E5=9E=8B=EF=BC=8C=E5=B9=B6=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- README.md | 2 + sql/README.md | 4 +- ...re_order_provider_fulfillment_20261016.sql | 89 +++++++++++++++++++ .../Dtos/PlatformStore/PlatformStoreDtos.cs | 8 +- .../PlatformShopFulfillmentClient.cs | 25 +++--- .../PlatformStoreOrderFulfillmentService.cs | 3 +- .../Orders/PlatformStoreOrderParcelBuilder.cs | 6 +- .../Orders/PlatformStoreOrderService.cs | 9 +- .../Entities/SalesStoreOrder.cs | 6 +- .../Entities/SalesStoreOrderLine.cs | 2 - 10 files changed, 122 insertions(+), 32 deletions(-) create mode 100644 sql/migrate_sales_store_order_provider_fulfillment_20261016.sql diff --git a/README.md b/README.md index a729463..6e2f875 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ public async Task BatchCreateFulfi 存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。 +新增增量迁移文件命名:`migrate_<主题>_YYYYMMDD.sql`(日期在末尾,例如 `migrate_xxx_20261016.sql`)。详细约定见 `sql/README.md`。 + ### 3. 上传代码前先同步远端 `test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。 diff --git a/sql/README.md b/sql/README.md index b691aa6..bb4f51a 100644 --- a/sql/README.md +++ b/sql/README.md @@ -14,4 +14,6 @@ OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。 - 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行) - 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过) - 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落 -- 单次增量迁移命名:`migrate_<主题>.sql`,尽量可重复执行(先判断列/表/字典是否已存在) +- 单次增量迁移命名:`migrate_<主题>_YYYYMMDD.sql`(日期放在文件名**末尾**,如 `migrate_sales_store_order_provider_fulfillment_20261016.sql`) +- 尽量可重复执行(先判断列/表/字典是否已存在) +- **后续新增脚本一律带日期后缀**;历史未带日期的脚本不强制改名 diff --git a/sql/migrate_sales_store_order_provider_fulfillment_20261016.sql b/sql/migrate_sales_store_order_provider_fulfillment_20261016.sql new file mode 100644 index 0000000..a5a2d9d --- /dev/null +++ b/sql/migrate_sales_store_order_provider_fulfillment_20261016.sql @@ -0,0 +1,89 @@ +-- 店铺订单:落库 shipping_provider / fulfillment_type;删除多面单、自定义标签、拒发字段。 +-- 可重复执行。 + +SET @db := DATABASE(); + +-- t_sales_store_order.ShippingProvider +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_sales_store_order' + AND COLUMN_NAME = 'ShippingProvider' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_sales_store_order` ADD COLUMN `ShippingProvider` VARCHAR(128) NULL COMMENT ''平台物流商'' AFTER `ShippingLabelUrl`', + 'SELECT ''t_sales_store_order.ShippingProvider already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- t_sales_store_order.FulfillmentType +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_sales_store_order' + AND COLUMN_NAME = 'FulfillmentType' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_sales_store_order` ADD COLUMN `FulfillmentType` VARCHAR(64) NULL COMMENT ''平台履约类型'' AFTER `ShippingProvider`', + 'SELECT ''t_sales_store_order.FulfillmentType already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- drop IsMultiLabel +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_sales_store_order' + AND COLUMN_NAME = 'IsMultiLabel' +); +SET @sql := IF( + @exists > 0, + 'ALTER TABLE `t_sales_store_order` DROP COLUMN `IsMultiLabel`', + 'SELECT ''t_sales_store_order.IsMultiLabel already dropped'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- drop CustomTags +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_sales_store_order' + AND COLUMN_NAME = 'CustomTags' +); +SET @sql := IF( + @exists > 0, + 'ALTER TABLE `t_sales_store_order` DROP COLUMN `CustomTags`', + 'SELECT ''t_sales_store_order.CustomTags already dropped'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- drop line IsShipExcluded +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_sales_store_order_line' + AND COLUMN_NAME = 'IsShipExcluded' +); +SET @sql := IF( + @exists > 0, + 'ALTER TABLE `t_sales_store_order_line` DROP COLUMN `IsShipExcluded`', + 'SELECT ''t_sales_store_order_line.IsShipExcluded already dropped'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs index 92ab5b9..f7a61ca 100644 --- a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs +++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs @@ -180,8 +180,6 @@ public class PlatformStoreOrderLineOutput public string? Currency { get; set; } - public bool IsShipExcluded { get; set; } - public int? MappedSkuId { get; set; } public string? MappedSkuCode { get; set; } @@ -245,9 +243,11 @@ public class PlatformStoreOrderOutput public string? ShippingLabelUrl { get; set; } - public bool IsMultiLabel { get; set; } + /// 平台物流商。 + public string? ShippingProvider { get; set; } - public string? CustomTags { get; set; } + /// 平台履约类型。 + public string? FulfillmentType { get; set; } public bool IsSuspended { get; set; } diff --git a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs index 9617dcd..40bac4c 100644 --- a/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs +++ b/src/Oms.Application/Integrations/PlatformShop/PlatformShopFulfillmentClient.cs @@ -99,7 +99,9 @@ public sealed class PlatformShopPulledOrder public string? ShippingLabelUrl { get; set; } - public bool IsMultiLabel { get; set; } + public string? ShippingProvider { get; set; } + + public string? FulfillmentType { get; set; } public string? ExtraJson { get; set; } @@ -784,13 +786,10 @@ internal sealed class PlatformShopFulfillmentClient( GetString(recipient, "region")); var lines = new List(); - var packageIds = new HashSet(StringComparer.Ordinal); if (order.TryGetProperty("line_items", out var lineItems) && lineItems.ValueKind == JsonValueKind.Array) { foreach (var line in lineItems.EnumerateArray()) { - var packageId = GetString(line, "package_id"); - if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId); lines.Add(new PlatformShopPulledOrderLine { PlatformLineId = GetString(line, "id"), @@ -806,15 +805,6 @@ internal sealed class PlatformShopFulfillmentClient( } } - if (order.TryGetProperty("packages", out var packages) && packages.ValueKind == JsonValueKind.Array) - { - foreach (var pkg in packages.EnumerateArray()) - { - var packageId = GetString(pkg, "id"); - if (!string.IsNullOrWhiteSpace(packageId)) packageIds.Add(packageId); - } - } - var tracking = GetString(order, "tracking_number"); if (string.IsNullOrWhiteSpace(tracking) && order.TryGetProperty("line_items", out var itemsForTrack) @@ -824,6 +814,12 @@ internal sealed class PlatformShopFulfillmentClient( tracking = GetString(itemsForTrack[0], "tracking_number"); } + var shippingProvider = FirstNonEmpty( + GetString(order, "shipping_provider"), + lineItems.ValueKind == JsonValueKind.Array && lineItems.GetArrayLength() > 0 + ? GetString(lineItems[0], "shipping_provider_name") + : null); + return new PlatformShopPulledOrder { PlatformOrderNo = id, @@ -848,7 +844,8 @@ internal sealed class PlatformShopFulfillmentClient( PlatformStatusLabel = status, FulfillStatus = MapTiktokFulfill(status), ShippingLabelNo = tracking, - IsMultiLabel = packageIds.Count > 1, + ShippingProvider = shippingProvider, + FulfillmentType = GetString(order, "fulfillment_type"), ExtraJson = order.ValueKind == JsonValueKind.Undefined ? null : order.GetRawText(), Lines = lines }; diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs index 6b4a8dd..72049d4 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderFulfillmentService.cs @@ -377,7 +377,8 @@ internal sealed class PlatformStoreOrderFulfillmentService( entity.FulfillStatus = pulled.FulfillStatus; entity.ShippingLabelNo = pulled.ShippingLabelNo ?? entity.ShippingLabelNo; entity.ShippingLabelUrl = pulled.ShippingLabelUrl ?? entity.ShippingLabelUrl; - entity.IsMultiLabel = pulled.IsMultiLabel || entity.IsMultiLabel; + if (!string.IsNullOrWhiteSpace(pulled.ShippingProvider)) entity.ShippingProvider = pulled.ShippingProvider; + if (!string.IsNullOrWhiteSpace(pulled.FulfillmentType)) entity.FulfillmentType = pulled.FulfillmentType; entity.PlatformExtraJson = pulled.ExtraJson ?? entity.PlatformExtraJson; entity.LastSyncTime = now; PlatformStoreOrderParcelBuilder.TryFillRecipientFields(entity, pulled.ExtraJson); diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs index ae10249..244ad2f 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs @@ -107,7 +107,7 @@ public static class PlatformStoreOrderParcelBuilder IReadOnlyCollection lines) { var items = new List(); - foreach (var line in lines.Where(x => !x.IsShipExcluded)) + foreach (var line in lines) { var cnName = string.IsNullOrWhiteSpace(line.ProductName) ? (string.IsNullOrWhiteSpace(line.PlatformSku) ? "Platform Item" : line.PlatformSku.Trim()) @@ -157,7 +157,7 @@ public static class PlatformStoreOrderParcelBuilder IReadOnlyDictionary skuMap) { var items = new List(); - foreach (var line in lines.Where(x => !x.IsShipExcluded)) + foreach (var line in lines) { if (!line.MappedSkuId.HasValue || !skuMap.TryGetValue(line.MappedSkuId.Value, out var sku)) { @@ -201,7 +201,7 @@ public static class PlatformStoreOrderParcelBuilder decimal maxHeight = DefaultHeightCm; decimal totalWeightKg = 0m; - foreach (var line in lines.Where(x => !x.IsShipExcluded && x.MappedSkuId.HasValue)) + foreach (var line in lines.Where(x => x.MappedSkuId.HasValue)) { if (!skuMap.TryGetValue(line.MappedSkuId!.Value, out var sku)) { diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs index e949a82..2f99043 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs @@ -553,7 +553,7 @@ public class PlatformStoreOrderService : ApplicationService var order = await GetOrderOrThrowAsync(customerId, storeOrderId); await EnsureCanCreateLogisticsAsync(order); - var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded); + var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id); if (lines.Count == 0) { throw new BusinessException("没有可发货的商品行"); @@ -710,7 +710,7 @@ public class PlatformStoreOrderService : ApplicationService var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value) ?? throw new BusinessException("仓库不存在"); - var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded); + var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id); if (lines.Count == 0) { throw new BusinessException("没有可发货的商品行"); @@ -1103,8 +1103,8 @@ public class PlatformStoreOrderService : ApplicationService PlatformStatusLabel = order.PlatformStatusLabel, ShippingLabelNo = order.ShippingLabelNo, ShippingLabelUrl = order.ShippingLabelUrl, - IsMultiLabel = order.IsMultiLabel, - CustomTags = order.CustomTags, + ShippingProvider = order.ShippingProvider, + FulfillmentType = order.FulfillmentType, IsSuspended = order.IsSuspended, WarehouseId = order.WarehouseId, LinkedOrderId = order.LinkedOrderId, @@ -1129,7 +1129,6 @@ public class PlatformStoreOrderService : ApplicationService UnitPrice = line.UnitPrice, Quantity = line.Quantity, Currency = line.Currency, - IsShipExcluded = line.IsShipExcluded, MappedSkuId = line.MappedSkuId, MappedSkuCode = mappedSkuCode }; diff --git a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs index a297d48..263d4f7 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs @@ -56,9 +56,11 @@ public class SalesStoreOrder : OmsAuditedAggregateRoot public string? ShippingLabelUrl { get; set; } - public bool IsMultiLabel { get; set; } + /// 平台物流商,如 TikTok shipping_provider。 + public string? ShippingProvider { get; set; } - public string? CustomTags { get; set; } + /// 平台履约类型,如 TikTok fulfillment_type。 + public string? FulfillmentType { get; set; } public bool IsSuspended { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs index dab91b5..58a657a 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs @@ -24,7 +24,5 @@ public class SalesStoreOrderLine : OmsAuditedAggregateRoot public string? Currency { get; set; } - public bool IsShipExcluded { get; set; } - public int? MappedSkuId { get; set; } } From 223d32f62ae8c450b0cefd53c9c98840062c5618 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 19 Oct 2026 14:01:00 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E8=AE=A2=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E6=97=B6=E9=80=89=E5=88=B8=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E9=A2=84=E8=A7=88=E6=8E=A5=E5=8F=A3=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E6=89=80=E9=80=89=E5=88=B8=E6=89=A3=E6=AC=BE=E6=A0=B8?= =?UTF-8?q?=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /order/pay-preview:返回会员折/券互斥后的应付拆解与本单可选券 - POST /order/pay 支持 couponInstanceId(null 沿用已绑定券,0 不用券,>0 指定券),按试算应付扣款并核销券、释放被替换券的锁定 - 被当前订单锁定的券视为可用,修复入库/编辑重算营销金额时误报券被占用 Co-authored-by: Cursor --- .../Dtos/Orders/OrderPayDtos.cs | 81 ++++++++++ .../Orders/OrderService.BatchWorker.cs | 2 +- .../Orders/OrderService.ParcelBilling.cs | 3 +- .../Services/Orders/OrderService.PayCoupon.cs | 142 ++++++++++++++++++ .../Services/Orders/OrderService.cs | 52 ++++--- .../Shared/MarketingCouponManager.cs | 39 ++++- .../Shared/MarketingOrderHelper.cs | 11 +- 7 files changed, 301 insertions(+), 29 deletions(-) create mode 100644 src/Oms.Application/Dtos/Orders/OrderPayDtos.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs diff --git a/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs b/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs new file mode 100644 index 0000000..6b3b12f --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs @@ -0,0 +1,81 @@ +using Oms.Application.Dtos.Marketing; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 订单支付入参(支付时选券)。 +/// +public class OrderPayInput : OrderActionInput +{ + /// + /// 选用的优惠券实例 Id:null 沿用订单已绑定的券(不可用则不使用);0 不使用券;大于 0 使用指定券。 + /// + public int? CouponInstanceId { get; set; } +} + +/// +/// 订单支付预览入参。 +/// +public class OrderPayPreviewInput +{ + /// + /// 订单主键。 + /// + public int Id { get; set; } + + /// + /// 试算用券:null 自动推荐(优先已绑定券,否则选优惠最大且优于会员折的券);0 不使用券;大于 0 使用指定券。 + /// + public int? CouponInstanceId { get; set; } +} + +/// +/// 订单支付预览:应付拆解与可选券。 +/// +public class OrderPayPreviewOutput +{ + /// + /// 订单主键。 + /// + public int Id { get; set; } + + /// + /// 应收小计(折前)。 + /// + public decimal ReceivableSubtotal { get; set; } + + /// + /// 基础运费(券门槛与抵扣口径)。 + /// + public decimal BaseFreightAmount { get; set; } + + /// + /// 会员折扣金额(与券互斥,用券时为 0)。 + /// + public decimal LevelDiscountAmount { get; set; } + + /// + /// 优惠券抵扣金额。 + /// + public decimal CouponDiscountAmount { get; set; } + + /// + /// 实际应付金额(支付扣款金额)。 + /// + public decimal PayableAmount { get; set; } + + /// + /// 会员等级名称。 + /// + public string? MemberLevelName { get; set; } + + /// + /// 本次试算实际生效的券实例 Id;为空表示未用券。 + /// + public int? AppliedCouponInstanceId { get; set; } + + /// + /// 当前客户可选券(CanUse=false 时附不可用原因,含「优惠不高于会员折扣」)。 + /// + public List AvailableCoupons { get; set; } = []; +} diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs index d15d384..e2e95c9 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs @@ -11,7 +11,7 @@ namespace Oms.Application.Services.Orders; public partial class OrderService { internal Task PayForBatchAsync(int orderId, CancellationToken cancellationToken = default) => - PayAsync(new OrderActionInput { Id = orderId }); + PayAsync(new OrderPayInput { Id = orderId }); internal Task PreDeclareForBatchAsync(int orderId, CancellationToken cancellationToken = default) => PreDeclareAsync(new OrderActionInput { Id = orderId }); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 9199c16..8e2609f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -3378,7 +3378,8 @@ public partial class OrderService receivableSubtotal, baseFreightAmount, order.CouponInstanceId, - order.ConsigneeCountry); + order.ConsigneeCountry, + order.Id); marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId); } diff --git a/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs new file mode 100644 index 0000000..9dbf1ff --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs @@ -0,0 +1,142 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Marketing; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private const string CouponNotBetterThanLevelReason = "优惠不高于会员折扣"; + + /// + /// 支付预览:按当前应收计算会员折/券互斥后的应付,并返回本单可选券。 + /// + /// 入参:订单 Id;couponInstanceId 为 null 自动推荐,0 不用券,大于 0 试算指定券。 + /// 应付拆解、实际生效券与可选券列表;指定券不可用(未解锁/过期/线路不符等)时抛业务异常。 + [HttpGet("/order/pay-preview")] + public async Task GetPayPreviewAsync([FromQuery] OrderPayPreviewInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); + var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees); + var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees); + var marketingHelper = LazyServiceProvider.GetRequiredService(); + + var memberOnly = await marketingHelper.BuildBreakdownAsync( + order.CustomerId, + receivableSubtotal, + baseFreightAmount, + null, + order.ConsigneeCountry, + order.Id); + var coupons = await marketingHelper.BuildAvailableCouponsAsync( + order.CustomerId, + baseFreightAmount, + order.ConsigneeCountry, + order.Id); + foreach (var coupon in coupons.Where(x => x.CanUse + && (x.EstimatedDiscount ?? 0M) <= memberOnly.LevelDiscountAmount)) + { + coupon.CanUse = false; + coupon.UnavailableReason = CouponNotBetterThanLevelReason; + } + + coupons = coupons + .OrderByDescending(x => x.CanUse) + .ThenByDescending(x => x.EstimatedDiscount ?? 0M) + .ThenBy(x => x.ValidEndTime) + .ToList(); + + var targetCouponId = input.CouponInstanceId switch + { + 0 => null, + > 0 => input.CouponInstanceId, + _ => ResolveRecommendedPayCouponId(order, coupons) + }; + + var breakdown = targetCouponId.HasValue + ? await marketingHelper.BuildBreakdownAsync( + order.CustomerId, + receivableSubtotal, + baseFreightAmount, + targetCouponId, + order.ConsigneeCountry, + order.Id) + : memberOnly; + + return new OrderPayPreviewOutput + { + Id = order.Id, + ReceivableSubtotal = breakdown.ReceivableSubtotal, + BaseFreightAmount = breakdown.BaseFreightAmount, + LevelDiscountAmount = breakdown.LevelDiscountAmount, + CouponDiscountAmount = breakdown.CouponDiscountAmount, + PayableAmount = breakdown.PayableAmount, + MemberLevelName = breakdown.MemberLevelName, + AppliedCouponInstanceId = breakdown.CouponInstanceId, + AvailableCoupons = coupons + }; + } + + /// + /// 计算支付应付:couponInstanceId 为 null 沿用订单已绑定券(不可用则不用券),0 不用券,大于 0 使用指定券。 + /// + private async Task BuildPayBreakdownAsync( + Order order, + IEnumerable fees, + int? couponInstanceId) + { + var feeList = fees as IList ?? fees.ToList(); + var receivableSubtotal = MarketingFeeHelper.SumReceivable(feeList); + var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(feeList); + var marketingHelper = LazyServiceProvider.GetRequiredService(); + + Task Build(int? couponId) => + marketingHelper.BuildBreakdownAsync( + order.CustomerId, + receivableSubtotal, + baseFreightAmount, + couponId, + order.ConsigneeCountry, + order.Id); + + if (couponInstanceId is > 0) + { + var breakdown = await Build(couponInstanceId); + if (breakdown.CouponInstanceId != couponInstanceId) + { + throw new BusinessException("所选优惠券未达使用门槛或优惠不高于会员折扣,请重新选择"); + } + + return breakdown; + } + + if (couponInstanceId == 0 || order.CouponInstanceId is not > 0) + { + return await Build(null); + } + + try + { + return await Build(order.CouponInstanceId); + } + catch (BusinessException) + { + return await Build(null); + } + } + + private static int? ResolveRecommendedPayCouponId(Order order, List coupons) + { + if (order.CouponInstanceId is > 0 + && coupons.Any(x => x.CanUse && x.CouponInstanceId == order.CouponInstanceId.Value)) + { + return order.CouponInstanceId; + } + + return coupons.FirstOrDefault(x => x.CanUse)?.CouponInstanceId; + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 7022a57..5751fe7 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -127,12 +127,12 @@ public partial class OrderService : ApplicationService } /// - /// 执行订单支付。 + /// 执行订单支付(余额扣款,支持支付时选券)。 /// - /// 入参:订单操作参数。 - /// 无返回体,仅完成订单支付处理。 + /// 入参:订单 Id;couponInstanceId 为 null 沿用已绑定券,0 不用券,大于 0 使用指定券。 + /// 无返回体;指定券不可用、余额不足或订单状态不可支付时抛业务异常。 [HttpPost("/order/pay")] - public async Task PayAsync(OrderActionInput input) + public async Task PayAsync(OrderPayInput input) { var order = await GetOrderOrThrowAsync(input.Id); if (order.IsPaid > 0) @@ -152,33 +152,42 @@ public partial class OrderService : ApplicationService var beforeSnap = await CaptureOrderAuditSnapshotAsync(order); var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); - var payAmount = order.PayableAmount ?? fees - .Where(x => x.Type == OrderFeeTypeCodes.Receivable) - .Sum(x => x.Amount); - if (payAmount <= 0) + var breakdown = await BuildPayBreakdownAsync(order, fees, input.CouponInstanceId); + if (breakdown.ReceivableSubtotal <= 0) { throw new BusinessException("订单应付金额无效,无法支付"); } + var payAmount = breakdown.PayableAmount; var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); if (customer == null) { throw new BusinessException("客户不存在"); } - var balanceMutator = LazyServiceProvider.GetRequiredService(); - await balanceMutator.DebitOrderPayAsync( - customer, - payAmount, - order.Id, - order.ServiceNo, - $"订单支付 {order.ServiceNo}"); - - if (order.CouponInstanceId.HasValue) + if (payAmount > 0) + { + var balanceMutator = LazyServiceProvider.GetRequiredService(); + await balanceMutator.DebitOrderPayAsync( + customer, + payAmount, + order.Id, + order.ServiceNo, + $"订单支付 {order.ServiceNo}"); + } + + var couponManager = LazyServiceProvider.GetRequiredService(); + if (order.CouponInstanceId is > 0 && order.CouponInstanceId != breakdown.CouponInstanceId) + { + await couponManager.ReleaseLockAsync(order.CouponInstanceId.Value, order.Id); + } + + LazyServiceProvider.GetRequiredService() + .ApplySnapshotToOrder(order, breakdown, null); + if (breakdown.CouponInstanceId.HasValue) { - var couponManager = LazyServiceProvider.GetRequiredService(); var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id); - await couponManager.MarkUsedAsync(order.CouponInstanceId.Value, mainCustomerId, order.Id); + await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id); } var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer); @@ -197,12 +206,15 @@ public partial class OrderService : ApplicationService order = await GetOrderOrThrowAsync(order.Id); var afterSnap = await CaptureOrderAuditSnapshotAsync(order); var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture); + var couponText = breakdown.CouponDiscountAmount > 0 + ? $",优惠券抵扣 {breakdown.CouponDiscountAmount.ToString("0.####", CultureInfo.InvariantCulture)}" + : string.Empty; await OperationLogWriter.TryWriteAsync( order, OrderOperationActionCodes.Pay, beforeSnap, afterSnap, - $"支付金额 {amountText}"); + $"支付金额 {amountText}{couponText}"); } /// diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs index e6ac36c..d944692 100644 --- a/src/Oms.Application/Shared/MarketingCouponManager.cs +++ b/src/Oms.Application/Shared/MarketingCouponManager.cs @@ -314,7 +314,10 @@ public class MarketingCouponManager : IScopedDependency await _instanceRepository.UpdateAsync(instance, cancellationToken); } - public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default) + /// + /// 释放券锁定;传 时仅释放被该订单锁定的券。 + /// + public async Task ReleaseLockAsync(int couponInstanceId, int? ownerOrderId = null, CancellationToken cancellationToken = default) { var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken); if (instance == null || instance.Status != CouponInstanceStatus.Locked) @@ -322,6 +325,11 @@ public class MarketingCouponManager : IScopedDependency return; } + if (ownerOrderId.HasValue && instance.OrderId != ownerOrderId.Value) + { + return; + } + instance.Status = CouponInstanceStatus.Available; instance.OrderId = null; instance.LockExpireTime = null; @@ -383,10 +391,14 @@ public class MarketingCouponManager : IScopedDependency } } + /// + /// 解析券抵扣参数; 为当前订单时,被该订单锁定的券视为可用。 + /// public async Task ResolveCouponDiscountInputAsync( int? couponInstanceId, int mainCustomerId, string? destinationCountry = null, + int? ownerOrderId = null, CancellationToken cancellationToken = default) { if (!couponInstanceId.HasValue) @@ -395,7 +407,7 @@ public class MarketingCouponManager : IScopedDependency } var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken); - EnsureUsable(instance); + EnsureUsable(instance, ownerOrderId); var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken) ?? throw new BusinessException("券模板不存在"); if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry)) @@ -422,13 +434,31 @@ public class MarketingCouponManager : IScopedDependency return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken); } + /// + /// 构建可选券列表;传 时额外包含被该订单锁定且仍在有效期内的券。 + /// public async Task> BuildAvailableCouponsAsync( int mainCustomerId, decimal? baseFreightAmount, string? destinationCountry = null, + int? ownerOrderId = null, CancellationToken cancellationToken = default) { var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken); + if (ownerOrderId is > 0) + { + var now = DateTime.Now; + var orderId = ownerOrderId.Value; + var ownLocked = await _instanceRepository.GetListAsync( + x => x.CustomerId == mainCustomerId + && x.Status == CouponInstanceStatus.Locked + && x.OrderId == orderId + && x.ValidStartTime <= now + && x.ValidEndTime >= now); + var knownIds = instances.Select(x => x.Id).ToHashSet(); + instances.AddRange(ownLocked.Where(x => !knownIds.Contains(x.Id))); + } + if (instances.Count == 0) { return []; @@ -541,7 +571,7 @@ public class MarketingCouponManager : IScopedDependency return instance; } - private static void EnsureUsable(CouponInstance instance) + private static void EnsureUsable(CouponInstance instance, int? ownerOrderId = null) { var now = DateTime.Now; if (instance.Status == CouponInstanceStatus.PendingUnlock) @@ -566,7 +596,8 @@ public class MarketingCouponManager : IScopedDependency if (instance.Status == CouponInstanceStatus.Locked && instance.LockExpireTime.HasValue - && instance.LockExpireTime.Value >= now) + && instance.LockExpireTime.Value >= now + && !(ownerOrderId.HasValue && instance.OrderId == ownerOrderId.Value)) { throw new BusinessException("优惠券已被其他订单占用"); } diff --git a/src/Oms.Application/Shared/MarketingOrderHelper.cs b/src/Oms.Application/Shared/MarketingOrderHelper.cs index f06a035..ce60a9b 100644 --- a/src/Oms.Application/Shared/MarketingOrderHelper.cs +++ b/src/Oms.Application/Shared/MarketingOrderHelper.cs @@ -53,15 +53,19 @@ public class MarketingOrderHelper : IScopedDependency baseFreightAmount, couponInstanceId, null, - cancellationToken); + cancellationToken: cancellationToken); } + /// + /// 计算会员折/券互斥后的应付拆解; 为当前订单时允许使用被该订单锁定的券。 + /// public async Task BuildBreakdownAsync( int? customerId, decimal receivableSubtotal, decimal baseFreightAmount, int? couponInstanceId, string? destinationCountry, + int? ownerOrderId = null, CancellationToken cancellationToken = default) { if (!customerId.HasValue || customerId.Value <= 0) @@ -87,7 +91,7 @@ public class MarketingOrderHelper : IScopedDependency var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey); var couponInput = await _couponManager.ResolveCouponDiscountInputAsync( - couponInstanceId, mainCustomerId, destinationCountry, cancellationToken); + couponInstanceId, mainCustomerId, destinationCountry, ownerOrderId, cancellationToken); return MarketingBillingCalculator.Calculate( receivableSubtotal, @@ -114,6 +118,7 @@ public class MarketingOrderHelper : IScopedDependency int? customerId, decimal? baseFreightAmount, string? destinationCountry = null, + int? ownerOrderId = null, CancellationToken cancellationToken = default) { if (!customerId.HasValue || customerId.Value <= 0) @@ -132,6 +137,6 @@ public class MarketingOrderHelper : IScopedDependency sessionCustomer, cancellationToken); return await _couponManager.BuildAvailableCouponsAsync( - mainCustomer.Id, baseFreightAmount, destinationCountry, cancellationToken); + mainCustomer.Id, baseFreightAmount, destinationCountry, ownerOrderId, cancellationToken); } }