From c6e765e6e2276b259be8053159fde2c18b2ca618 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 09:48:42 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20=E5=85=AC=E5=91=8A=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=94=A8=20mapId=20=E8=A7=A3=E6=9E=90=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E7=94=A8=E6=88=B7=EF=BC=8C=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E5=8F=AF=E6=9F=A5=E5=85=A8=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步监控 overview 支持日周月经营指标与积压队列。 Co-authored-by: Cursor --- .../Dtos/Monitor/MonitorOverviewOutput.cs | 65 ++++++++- .../Dtos/System/NoticeListInput.cs | 5 + .../Services/Orders/OrderService.Monitor.cs | 129 +++++++++++++++--- .../Services/System/NoticeService.cs | 36 +++-- .../Shared/OmsSessionHelper.cs | 37 +++-- 5 files changed, 228 insertions(+), 44 deletions(-) diff --git a/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs index a8a9138..6f03ec8 100644 --- a/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs +++ b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs @@ -10,12 +10,32 @@ public class MonitorOverviewOutput public MonitorCustomerStatsOutput Customer { get; set; } = new(); /// - /// 近 7 日新增订单趋势。 + /// 经营指标(订单量/营收/毛利)按日周月维度。 + /// + public MonitorOpsStatsOutput Ops { get; set; } = new(); + + /// + /// 实时积压队列(不随维度变化)。 + /// + public MonitorQueueStatsOutput Queue { get; set; } = new(); + + /// + /// 订单趋势(随 dimension 变化;兼容字段名 OrderTrend7d 在 day 时等同本字段)。 + /// + public List OrderTrend { get; set; } = []; + + /// + /// 近 7 日新增订单趋势(兼容旧前端;dimension=day 时与 OrderTrend 相同)。 /// public List OrderTrend7d { get; set; } = []; /// - /// 近 7 日营收/毛利趋势。 + /// 营收/毛利趋势(随 dimension 变化)。 + /// + public List RevenueTrend { get; set; } = []; + + /// + /// 近 7 日营收/毛利趋势(兼容旧前端;dimension=day 时与 RevenueTrend 相同)。 /// public List RevenueTrend7d { get; set; } = []; @@ -40,6 +60,47 @@ public class MonitorOverviewOutput public MonitorTrackRatesOutput TrackRates { get; set; } = new(); } +/// +/// 经营 KPI:订单量 / 营收 / 毛利(随日周月)。 +/// +public class MonitorOpsStatsOutput +{ + /// day / week / month + public string Dimension { get; set; } = "day"; + + /// 当前周期新建订单数。 + public int OrderCount { get; set; } + + /// 订单量环比增长百分比。 + public decimal? OrderCountGrowthPercent { get; set; } + + /// 当前周期应收(已付)营收。 + public decimal Revenue { get; set; } + + /// 营收环比增长百分比。 + public decimal? RevenueGrowthPercent { get; set; } + + /// 当前周期毛利 = 应收 - 应付。 + public decimal GrossProfit { get; set; } + + /// 毛利环比增长百分比。 + public decimal? GrossProfitGrowthPercent { get; set; } +} + +/// +/// 实时积压快照(不受日周月影响)。 +/// +public class MonitorQueueStatsOutput +{ + public int PendingPayment { get; set; } + + public int PendingOnline { get; set; } + + public int PendingSign { get; set; } + + public int Problematic { get; set; } +} + public class MonitorOrderStatsOutput { public int TodayCreated { get; set; } diff --git a/src/Oms.Application/Dtos/System/NoticeListInput.cs b/src/Oms.Application/Dtos/System/NoticeListInput.cs index 4707ffa..51f5af1 100644 --- a/src/Oms.Application/Dtos/System/NoticeListInput.cs +++ b/src/Oms.Application/Dtos/System/NoticeListInput.cs @@ -34,4 +34,9 @@ public class NoticeListInput /// 仅未读(相对当前登录主体)。 /// public bool? OnlyUnread { get; set; } + + /// + /// 管理端查看全部通知(仅非客户登录生效)。默认按当前主体可见范围过滤。 + /// + public bool? Manage { get; set; } } diff --git a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs index ac443f2..f5a04fc 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs @@ -12,9 +12,12 @@ public partial class OrderService /// /// 实时监控页概览(订单/客户统计与趋势)。 /// + /// 经营指标维度:day / week / month,默认 day。 [HttpGet("/monitor/overview")] - public async Task GetMonitorOverviewAsync() + public async Task GetMonitorOverviewAsync( + [FromQuery] string? dimension = "day") { + var dim = NormalizeMonitorDimension(dimension); var filter = await BuildScopedListFilterAsync(); var today = DateTime.Today; var tomorrow = today.AddDays(1); @@ -28,21 +31,60 @@ public partial class OrderService int GetCount(string status) => counts.TryGetValue(status, out var value) ? value : 0; - var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow); - var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today); + var pendingPayment = GetCount(OrderStatusCodes.PendingPayment); + var pendingOnline = GetCount(OrderStatusCodes.PendingOnline); + var pendingSign = GetCount(OrderStatusCodes.Shipping); + var problematic = GetCount(OrderStatusCodes.Problematic); + + var (currentStart, currentEndExclusive, previousStart, previousEndExclusive) = + ResolveOpsPeriodRanges(dim, today, tomorrow); + + var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync( + filter, currentStart, currentEndExclusive); + var previousOrderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync( + filter, previousStart, previousEndExclusive); + + var revenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Receivable, currentStart, currentEndExclusive); + var previousRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Receivable, previousStart, previousEndExclusive); + + var payable = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Payable, currentStart, currentEndExclusive); + var previousPayable = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Payable, previousStart, previousEndExclusive); + var grossProfit = revenue - payable; + var previousGrossProfit = previousRevenue - previousPayable; + + // 兼容旧字段:始终提供「今日」口径 + var todayCreated = dim == "day" + ? orderCount + : await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow); + var yesterdayCreated = dim == "day" + ? previousOrderCount + : await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today); + var todayRevenue = dim == "day" + ? revenue + : await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Receivable, today, tomorrow); + var yesterdayRevenue = dim == "day" + ? previousRevenue + : await OrderRepository.SumFeeAmountByPayTimeRangeAsync( + filter, OrderFeeTypeCodes.Receivable, yesterday, today); var monthStart = new DateTime(today.Year, today.Month, 1); var lastMonthStart = monthStart.AddMonths(-1); var thisMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, monthStart, tomorrow); var lastMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, lastMonthStart, monthStart); - var todayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( - filter, OrderFeeTypeCodes.Receivable, today, tomorrow); - var yesterdayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync( - filter, OrderFeeTypeCodes.Receivable, yesterday, today); - - var orderTrend7d = await BuildMonitorOrderTrendAsync(filter, 7); - var revenueTrend7d = await BuildMonitorRevenueTrendAsync(filter, 7); + var trendCount = dim switch + { + "week" => 8, + "month" => 6, + _ => 7, + }; + var orderTrend = await BuildMonitorOrderTrendAsync(filter, dim, trendCount); + var revenueTrend = await BuildMonitorRevenueTrendAsync(filter, dim, trendCount); var recentFilter = CloneFilterWithCreateTimeStart(filter, today.AddDays(-90)); var recentOrders = await OrderRepository.GetFilteredListAsync(recentFilter); @@ -83,13 +125,30 @@ public partial class OrderService TodayCreatedGrowthPercent = CalcGrowthPercent(todayCreated, yesterdayCreated), Total = counts.Values.Sum(), TotalMonthGrowthPercent = CalcGrowthPercent(thisMonthCreated, lastMonthCreated), - PendingPayment = GetCount(OrderStatusCodes.PendingPayment), - PendingOnline = GetCount(OrderStatusCodes.PendingOnline), - PendingSign = GetCount(OrderStatusCodes.Shipping), - Problematic = GetCount(OrderStatusCodes.Problematic), + PendingPayment = pendingPayment, + PendingOnline = pendingOnline, + PendingSign = pendingSign, + Problematic = problematic, TodayRevenue = todayRevenue, TodayRevenueGrowthPercent = CalcGrowthPercentDecimal(todayRevenue, yesterdayRevenue), }, + Ops = new MonitorOpsStatsOutput + { + Dimension = dim, + OrderCount = orderCount, + OrderCountGrowthPercent = CalcGrowthPercent(orderCount, previousOrderCount), + Revenue = revenue, + RevenueGrowthPercent = CalcGrowthPercentDecimal(revenue, previousRevenue), + GrossProfit = grossProfit, + GrossProfitGrowthPercent = CalcGrowthPercentDecimal(grossProfit, previousGrossProfit), + }, + Queue = new MonitorQueueStatsOutput + { + PendingPayment = pendingPayment, + PendingOnline = pendingOnline, + PendingSign = pendingSign, + Problematic = problematic, + }, Customer = new MonitorCustomerStatsOutput { TotalCustomers = totalCustomers, @@ -100,8 +159,10 @@ public partial class OrderService InactiveLogin7d = inactiveLogin7d, InactiveLogin7dDelta = inactiveLogin7d - inactiveLogin14d, }, - OrderTrend7d = orderTrend7d, - RevenueTrend7d = revenueTrend7d, + OrderTrend = orderTrend, + OrderTrend7d = dim == "day" ? orderTrend : await BuildMonitorOrderTrendAsync(filter, "day", 7), + RevenueTrend = revenueTrend, + RevenueTrend7d = dim == "day" ? revenueTrend : await BuildMonitorRevenueTrendAsync(filter, "day", 7), RegionOrderTrend = new MonitorRegionTrendBundleOutput { Week = new MonitorRegionTrendPeriodOutput { Labels = weekOrderLabels, Series = weekOrderSeries }, @@ -117,11 +178,42 @@ public partial class OrderService }; } + private static string NormalizeMonitorDimension(string? dimension) + { + var dim = (dimension ?? "day").Trim().ToLowerInvariant(); + return dim is "day" or "week" or "month" ? dim : "day"; + } + + /// + /// 当前周期(统计到今天结束)与上一完整同尺度周期。 + /// + private static (DateTime CurrentStart, DateTime CurrentEndExclusive, DateTime PreviousStart, DateTime PreviousEndExclusive) + ResolveOpsPeriodRanges(string dimension, DateTime today, DateTime tomorrow) + { + if (dimension == "week") + { + var weekStart = GetWeekStart(today); + var previousWeekStart = weekStart.AddDays(-7); + return (weekStart, tomorrow, previousWeekStart, weekStart); + } + + if (dimension == "month") + { + var monthStart = new DateTime(today.Year, today.Month, 1); + var previousMonthStart = monthStart.AddMonths(-1); + return (monthStart, tomorrow, previousMonthStart, monthStart); + } + + var yesterday = today.AddDays(-1); + return (today, tomorrow, yesterday, today); + } + private async Task> BuildMonitorOrderTrendAsync( OrderListFilter filter, + string dimension, int count) { - var buckets = BuildTrendBuckets("day", count); + var buckets = BuildTrendBuckets(dimension, count); var points = new List(buckets.Count); foreach (var bucket in buckets) { @@ -135,9 +227,10 @@ public partial class OrderService private async Task> BuildMonitorRevenueTrendAsync( OrderListFilter filter, + string dimension, int count) { - var buckets = BuildTrendBuckets("day", count); + var buckets = BuildTrendBuckets(dimension, count); var points = new List(buckets.Count); foreach (var bucket in buckets) { diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs index ca82fe3..f01b26d 100644 --- a/src/Oms.Application/Services/System/NoticeService.cs +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -49,15 +49,16 @@ public class NoticeService : ApplicationService [HttpGet("/system/notice/list")] public async Task> GetListAsync([FromQuery] NoticeListInput input) { - var receiverUserId = CurrentUser.GetId(); + var receiverUserId = OmsSessionHelper.ResolveMapUserId(CurrentUser); if (receiverUserId <= 0) { return new OmsPageResult { Items = [], Total = 0 }; } - var expectedReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser) - ? ReceiverTypeCustomer - : ReceiverTypeUser; + var isCustomer = OmsSessionHelper.IsCustomerLogin(CurrentUser); + var expectedReceiverType = isCustomer ? ReceiverTypeCustomer : ReceiverTypeUser; + // 管理端公告页:管理员可看全部;工作台/H5/未读仍按接收范围过滤 + var manageAll = input.Manage == true && !isCustomer; var myReadNoticeIds = (await NoticeReadRepository.GetListAsync(x => x.ReceiverType == expectedReceiverType && x.ReceiverId == receiverUserId)) @@ -65,11 +66,13 @@ public class NoticeService : ApplicationService .Distinct() .ToList(); - var predicate = BuildListPredicate( - input, - receiverUserId, - expectedReceiverType, - input.OnlyUnread == true ? myReadNoticeIds : null); + var predicate = manageAll + ? BuildManageListPredicate(input) + : BuildListPredicate( + input, + receiverUserId, + expectedReceiverType, + input.OnlyUnread == true ? myReadNoticeIds : null); var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var notices = await NoticeRepository.GetPagedListAsync( @@ -197,7 +200,7 @@ public class NoticeService : ApplicationService var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser) ? ReceiverTypeCustomer : ReceiverTypeUser; - var currentReceiverId = CurrentUser.GetId(); + var currentReceiverId = OmsSessionHelper.ResolveMapUserId(CurrentUser); if (currentReceiverId <= 0) { throw new BusinessException("当前登录主体无效"); @@ -349,6 +352,19 @@ public class NoticeService : ApplicationService return result; } + private static Expression> BuildManageListPredicate(NoticeListInput input) + { + var noticeType = input.NoticeType?.Trim(); + var receiverType = input.ReceiverType?.Trim(); + var keyword = input.Keyword?.Trim(); + return x => + (string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) && + (string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) && + (string.IsNullOrWhiteSpace(keyword) || + x.Title.Contains(keyword) || + x.Content.Contains(keyword)); + } + private static Expression> BuildListPredicate( NoticeListInput input, int receiverUserId, diff --git a/src/Oms.Application/Shared/OmsSessionHelper.cs b/src/Oms.Application/Shared/OmsSessionHelper.cs index 3b603fd..c5ff2b4 100644 --- a/src/Oms.Application/Shared/OmsSessionHelper.cs +++ b/src/Oms.Application/Shared/OmsSessionHelper.cs @@ -43,6 +43,27 @@ public static class OmsSessionHelper return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase); } + /// + /// 解析当前会话的 RBAC mapId(user.userid)。 + /// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。 + /// + public static int ResolveMapUserId(ICurrentUser currentUser) + { + var userId = currentUser.GetId(); + if (userId > 0) + { + return userId; + } + + var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value; + if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0) + { + return mapId; + } + + return 0; + } + /// /// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。 /// @@ -53,20 +74,8 @@ public static class OmsSessionHelper return null; } - var userId = currentUser.GetId(); - if (userId > 0) - { - return userId; - } - - // GetId 未绑到 mapId 时,直接读 claim,避免客户会话被当成管理员(不过滤) - var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value; - if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0) - { - return mapId; - } - - return null; + var mapId = ResolveMapUserId(currentUser); + return mapId > 0 ? mapId : null; } /// -- 2.45.2 From 7a0affe2d48e3eb70e0cb70c077b146cd8ac994d Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 09:57:25 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20=E5=85=AC=E5=91=8A=E5=B9=BF?= =?UTF-8?q?=E6=92=AD=E5=8F=AF=E8=A7=81=E6=80=A7=E6=94=B9=E4=B8=BA=E5=8F=8D?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E5=88=A4=E5=AE=9A=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20JSON=20=E5=88=97=E6=AF=94=E8=BE=83=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReceiverIds 改为 text 存储并补充迁移;广播对客户/收件箱可见。 Co-authored-by: Cursor --- db.sql | 2 +- sql/migrate_notice_receiver_ids_text.sql | 25 +++++++ .../Services/System/NoticeService.cs | 69 ++++++++----------- .../SystemAggregate/Entities/Notice.cs | 4 ++ 4 files changed, 58 insertions(+), 42 deletions(-) create mode 100644 sql/migrate_notice_receiver_ids_text.sql diff --git a/db.sql b/db.sql index 3f1111e..2661036 100644 --- a/db.sql +++ b/db.sql @@ -1220,7 +1220,7 @@ CREATE TABLE `t_notice` ( `Content` TEXT NOT NULL COMMENT '通知内容', `IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是', `ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer', - `ReceiverIds` JSON NOT NULL COMMENT '接收对象ID列表(JSON数组)', + `ReceiverIds` TEXT NOT NULL COMMENT '接收对象ID列表(JSON数组字符串)', `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', diff --git a/sql/migrate_notice_receiver_ids_text.sql b/sql/migrate_notice_receiver_ids_text.sql new file mode 100644 index 0000000..d4bcd97 --- /dev/null +++ b/sql/migrate_notice_receiver_ids_text.sql @@ -0,0 +1,25 @@ +-- ============================================================================= +-- migrate_notice_receiver_ids_text.sql +-- t_notice.ReceiverIds:JSON → TEXT,保证广播([])与定向名单可用字符串条件查询 +-- 可重复执行 +-- ============================================================================= +SET @db := DATABASE(); + +SET @col_type := ( + SELECT DATA_TYPE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_notice' + AND COLUMN_NAME = 'ReceiverIds' + LIMIT 1 +); + +SET @sql := IF( + @col_type IS NOT NULL AND LOWER(IFNULL(@col_type, '')) = 'json', + 'ALTER TABLE `t_notice` + MODIFY COLUMN `ReceiverIds` TEXT NOT NULL COMMENT ''接收对象ID列表(JSON数组字符串)''', + 'SELECT ''t_notice.ReceiverIds already text or missing'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs index f01b26d..e3238b6 100644 --- a/src/Oms.Application/Services/System/NoticeService.cs +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -65,22 +65,25 @@ public class NoticeService : ApplicationService .Select(x => x.NoticeId) .Distinct() .ToList(); + var myReadIdSet = myReadNoticeIds.ToHashSet(); - var predicate = manageAll - ? BuildManageListPredicate(input) - : BuildListPredicate( - input, - receiverUserId, - expectedReceiverType, - input.OnlyUnread == true ? myReadNoticeIds : null); + // 先按类型/关键词查,再在内存按 ReceiverIds 判可见性。 + // MySQL JSON 列与字符串 "[]" 比较会失败,导致广播对客户/收件箱不可见。 + var basePredicate = BuildManageListPredicate(input); + var candidates = await NoticeRepository.GetListAsync(basePredicate); + var visible = candidates + .Where(x => manageAll || IsVisibleToReceiver(x, receiverUserId, expectedReceiverType)) + .Where(x => input.OnlyUnread != true || !myReadIdSet.Contains(x.Id)) + .OrderByDescending(x => x.CreateTime) + .ThenByDescending(x => x.Id) + .ToList(); var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); - var notices = await NoticeRepository.GetPagedListAsync( - predicate, - pageIndex, - pageSize, - new Dictionary { [nameof(Notice.CreateTime)] = false, [nameof(Notice.Id)] = false }); - var total = await NoticeRepository.CountAsync(predicate); + var total = visible.Count; + var notices = visible + .Skip(Math.Max(pageIndex - 1, 0) * pageSize) + .Take(pageSize) + .ToList(); var noticeIds = notices.Select(x => x.Id).Distinct().ToArray(); var reads = noticeIds.Length == 0 @@ -91,8 +94,6 @@ public class NoticeService : ApplicationService .ToDictionary( x => x.Key, x => (int)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count()); - var myReadIdSet = myReadNoticeIds.ToHashSet(); - var selectionIdMap = await BuildReceiverSelectionIdMapAsync(notices); return new OmsPageResult @@ -365,33 +366,19 @@ public class NoticeService : ApplicationService x.Content.Contains(keyword)); } - private static Expression> BuildListPredicate( - NoticeListInput input, - int receiverUserId, - string expectedReceiverType, - IReadOnlyCollection? excludeNoticeIds) + /// + /// 空接收人 = 全体广播(员工/客户均可见);否则须类型匹配且 mapId 在名单内。 + /// + private static bool IsVisibleToReceiver(Notice notice, int receiverUserId, string expectedReceiverType) { - var noticeType = input.NoticeType?.Trim(); - var receiverType = input.ReceiverType?.Trim(); - var keyword = input.Keyword?.Trim(); - var receiverIdToken = receiverUserId.ToString(); - var excluded = excludeNoticeIds?.Where(x => x > 0).Distinct().ToList() ?? []; - return x => - (string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) && - (string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) && - (string.IsNullOrWhiteSpace(keyword) || - x.Title.Contains(keyword) || - x.Content.Contains(keyword)) && - ( - // 空接收人列表 = 全体广播(员工/客户均可见) - x.ReceiverIds == "[]" || - (x.ReceiverType == expectedReceiverType && - (x.ReceiverIds == $"[{receiverIdToken}]" || - x.ReceiverIds.StartsWith($"[{receiverIdToken},") || - x.ReceiverIds.Contains($",{receiverIdToken},") || - x.ReceiverIds.EndsWith($",{receiverIdToken}]"))) - ) && - (excluded.Count == 0 || !excluded.Contains(x.Id)); + var receiverIds = OmsDocHelper.DeserializeJson>(notice.ReceiverIds) ?? []; + if (receiverIds.Count == 0) + { + return true; + } + + return string.Equals(notice.ReceiverType, expectedReceiverType, StringComparison.OrdinalIgnoreCase) + && receiverIds.Contains(receiverUserId); } private async Task GetNoticeOrThrowAsync(int id) diff --git a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs index e8fb134..3b2afa6 100644 --- a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs +++ b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs @@ -16,5 +16,9 @@ public class Notice : OmsAuditedAggregateRoot public string ReceiverType { get; set; } = string.Empty; + /// + /// 接收人 mapId 列表 JSON 数组字符串,如 [] / [1,2]。用 text 存储,避免 MySQL JSON 列与字符串比较失败导致广播不可见。 + /// + [SugarColumn(ColumnDataType = "text")] public string ReceiverIds { get; set; } = "[]"; } -- 2.45.2 From d72637e472cfdeeaec64149e1d2f1b98f3172f1a Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Fri, 16 Oct 2026 14:55:37 +0800 Subject: [PATCH 03/15] =?UTF-8?q?=E4=BC=98=E5=8C=96order/parcel/create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SmallBagTrackOrderNotifier.cs | 28 +- .../Messaging/IOrderBatchJobPublisher.cs | 8 - .../Messaging/OmsAfterCommit.cs | 57 --- .../Messaging/OmsIntegrationEventPublisher.cs | 119 ----- .../Messaging/OrderBatchJobPublisher.cs | 36 -- .../IOrderSideEffectEventPublisher.cs | 22 - .../OrderSideEffectEventPublisher.cs | 61 --- .../IOrderParcelCreateEventPublisher.cs | 46 -- .../OrderParcelCreateEventPublisher.cs | 143 ------ .../OrderBatchMessagingServiceExtensions.cs | 5 - .../Orders/Batch/OrderBatchActionExecutor.cs | 16 +- .../Orders/Batch/OrderBatchJobCoordinator.cs | 32 +- .../Issues/AutoTriggerRuntimeService.cs | 10 +- .../Orders/OrderService.BatchCreate.cs | 9 +- .../Services/Orders/OrderService.Helpers.cs | 2 +- .../Orders/OrderService.ParcelBilling.cs | 479 +++++------------- .../Services/Orders/OrderService.cs | 62 +-- .../OrderBatchJobOutboxPublisherTests.cs | 31 +- .../OrderParcelCreateOutboxPublisherTests.cs | 48 +- .../OrderSideEffectOutboxPublisherTests.cs | 20 +- ...OrderBatchJobCoordinatorGovernanceTests.cs | 20 +- 21 files changed, 256 insertions(+), 998 deletions(-) delete mode 100644 src/Oms.Application/Messaging/IOrderBatchJobPublisher.cs delete mode 100644 src/Oms.Application/Messaging/OmsAfterCommit.cs delete mode 100644 src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs delete mode 100644 src/Oms.Application/Messaging/OrderBatchJobPublisher.cs delete mode 100644 src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs delete mode 100644 src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs delete mode 100644 src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs delete mode 100644 src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs index 84a3c24..d73df52 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackOrderNotifier.cs @@ -1,5 +1,7 @@ +using Fake.EventBus.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; using Oms.Application.Messaging.OrderSideEffects; using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Services.Orders.Batch; @@ -25,8 +27,15 @@ internal sealed class SmallBagTrackOrderNotifier( return; } - var publisher = scope.ServiceProvider.GetRequiredService(); - await publisher.PublishTrackUpsertAsync(order, request, customerId); + var eventBus = scope.ServiceProvider.GetRequiredService(); + await eventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent + { + OrderId = order.Id, + CustomerId = customerId, + PayloadJson = JsonConvert.SerializeObject(request), + AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess, + Authorization = request.Authorization + }); } catch (Exception ex) { @@ -75,8 +84,19 @@ internal sealed class SmallBagTrackOrderNotifier( try { using var scope = scopeFactory.CreateScope(); - var publisher = scope.ServiceProvider.GetRequiredService(); - await publisher.PublishLifecycleTrackAppendAsync(orderId, orderNo, trackCode, authorization); + if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode)) + { + return; + } + + var eventBus = scope.ServiceProvider.GetRequiredService(); + await eventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent + { + OrderId = orderId, + OrderNo = orderNo.Trim(), + TrackCode = trackCode.Trim(), + Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim() + }); } catch (Exception ex) { diff --git a/src/Oms.Application/Messaging/IOrderBatchJobPublisher.cs b/src/Oms.Application/Messaging/IOrderBatchJobPublisher.cs deleted file mode 100644 index 40054e3..0000000 --- a/src/Oms.Application/Messaging/IOrderBatchJobPublisher.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Oms.Application.Messaging; - -public interface IOrderBatchJobPublisher -{ - Task PublishAsync(OrderBatchJobMessage message, CancellationToken cancellationToken = default); - - bool IsEnabled { get; } -} diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs deleted file mode 100644 index 9b31ee7..0000000 --- a/src/Oms.Application/Messaging/OmsAfterCommit.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Fake.UnitOfWork; - -namespace Oms.Application.Messaging; - -/// -/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 -/// -internal static class OmsAfterCommit -{ - public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) - { - var current = unitOfWorkManager.Current; - if (current != null && !current.IsCompleted) - { - // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 - // 等工作单元释放后再开独立事务。 - EventHandler handler = null!; - handler = (_, _) => - { - current.Disposed -= handler; - _ = Task.Run(action); - }; - current.Disposed += handler; - return Task.CompletedTask; - } - - return action(); - } - - /// - /// 新开独立事务执行一步。失败只记日志,不抛回调用方。 - /// - public static async Task RunStepAsync( - IServiceScopeFactory scopeFactory, - ILogger logger, - string step, - Func work) - { - try - { - using var scope = scopeFactory.CreateScope(); - var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); - await work(scope.ServiceProvider); - await unitOfWork.CompleteAsync(); - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "订单后置步骤失败 Step={Step}", step); - return false; - } - } -} diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs deleted file mode 100644 index b572be4..0000000 --- a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Data; -using Fake.EntityFrameworkCore; -using Fake.EventBus.Distributed; -using Fake.EventBus.IntegrationEventLog; -using Fake.UnitOfWork; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Oms.Application.Messaging; - -/// -/// 可靠发布 IntegrationEvent:事务 UoW 内写入 Outbox(由 Fake.EventBus.IntegrationEventLog 投递)。 -/// SqlSugar 与 EF 默认各持连接;若环境事务已由 SqlSugar 占用,则改用 RequiresNew 短事务只写 Outbox。 -/// -internal static class OmsIntegrationEventPublisher -{ - public static async Task PublishAsync( - IDistributedEventBus eventBus, - IUnitOfWorkManager unitOfWorkManager, - ILogger logger, - IntegrationEvent @event, - CancellationToken cancellationToken = default) - { - if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager)) - { - try - { - await EnsureOutboxDbContextAsync(unitOfWorkManager); - await eventBus.PublishAsync(@event, cancellationToken); - logger.LogDebug( - "Outbox enqueued {EventName} Id={EventId} (ambient TX)", - @event.GetType().Name, - @event.Id); - return; - } - catch (Exception ex) when (ShouldUseIndependentOutbox(ex)) - { - // 环境事务已提交、连接对不上,或还没有可征用的数据库事务时,改走独立短事务。 - logger.LogWarning( - ex, - "Ambient UoW TX incompatible with EF Outbox; using short outbox TX. Event={EventName} Id={EventId}", - @event.GetType().Name, - @event.Id); - } - } - else - { - logger.LogWarning( - "Publish {EventName} Id={EventId} without ambient transactional UoW; opening short outbox TX", - @event.GetType().Name, - @event.Id); - } - - using var uow = unitOfWorkManager.Begin(new UnitOfWorkAttribute - { - IsTransactional = true, - RequiresNew = true, - IsolationLevel = IsolationLevel.ReadCommitted - }); - await EnsureOutboxDbContextAsync(unitOfWorkManager); - await eventBus.PublishAsync(@event, cancellationToken); - await uow.CompleteAsync(cancellationToken); - } - - public static async Task TryPublishAsync( - IDistributedEventBus eventBus, - IUnitOfWorkManager unitOfWorkManager, - ILogger logger, - IntegrationEvent @event, - CancellationToken cancellationToken = default) - { - try - { - await PublishAsync(eventBus, unitOfWorkManager, logger, @event, cancellationToken); - return true; - } - catch (Exception ex) - { - logger.LogError( - ex, - "Outbox publish failed {EventName} Id={EventId}", - @event.GetType().Name, - @event.Id); - return false; - } - } - - /// - /// 业务写库前先打开 Outbox DbContext,尽量让 EF 先占住环境事务连接,供 SqlSugar 征用。 - /// - public static Task EnsureOutboxDbContextAsync(IUnitOfWorkManager unitOfWorkManager) - { - var current = unitOfWorkManager.Current - ?? throw new InvalidOperationException("No ambient unit of work"); - var dbProvider = current.ServiceProvider - .GetRequiredService>(); - return dbProvider.GetDbContextAsync(); - } - - private static bool ShouldUseIndependentOutbox(Exception ex) - { - for (var e = ex; e != null; e = e.InnerException!) - { - var message = e.Message; - if (message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase) - || message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase) - || message.Contains("可征用的数据库事务", StringComparison.Ordinal) - || message.Contains("returned null", StringComparison.OrdinalIgnoreCase) - || message.Contains("No ambient transaction", StringComparison.OrdinalIgnoreCase) - || message.Contains("已经回滚", StringComparison.Ordinal) - || message.Contains("已被调用", StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } -} diff --git a/src/Oms.Application/Messaging/OrderBatchJobPublisher.cs b/src/Oms.Application/Messaging/OrderBatchJobPublisher.cs deleted file mode 100644 index a007ac4..0000000 --- a/src/Oms.Application/Messaging/OrderBatchJobPublisher.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Fake.EventBus.Distributed; -using Fake.UnitOfWork; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Oms.Application.Messaging; - -/// -/// 通过 Fake 分布式事件总线(Outbox)发布订单批量任务。 -/// -public sealed class OrderBatchJobPublisher( - IDistributedEventBus eventBus, - IUnitOfWorkManager unitOfWorkManager, - IOptions options, - ILogger logger) : IOrderBatchJobPublisher -{ - public bool IsEnabled => options.Value.Enabled; - - public async Task PublishAsync(OrderBatchJobMessage message, CancellationToken cancellationToken = default) - { - if (!options.Value.Enabled) - { - throw new InvalidOperationException("Order batch RabbitMQ is disabled."); - } - - var @event = OrderBatchJobIntegrationEvent.FromMessage(message); - await OmsIntegrationEventPublisher.PublishAsync( - eventBus, unitOfWorkManager, logger, @event, cancellationToken); - - logger.LogDebug( - "Outbox enqueued order batch event {EventId} messageId={MessageId} action={Action}", - @event.Id, - message.MessageId, - message.Action); - } -} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs deleted file mode 100644 index 9cba9f9..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffects/IOrderSideEffectEventPublisher.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Oms.Application.Services.Orders.Issues; - -namespace Oms.Application.Messaging.OrderSideEffects; - -/// -/// 订单侧后置副作用发布(生命周期轨迹、自动触发等)。 -/// -public interface IOrderSideEffectEventPublisher -{ - Task PublishLifecycleTrackAppendAsync( - int orderId, - string orderNo, - string trackCode, - string? authorization = null, - CancellationToken cancellationToken = default); - - Task PublishAutoTriggerFireAsync( - int orderId, - AutoTriggerTiming timing, - int operatorUserId, - CancellationToken cancellationToken = default); -} diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs deleted file mode 100644 index 9322fc4..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffects/OrderSideEffectEventPublisher.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Fake.EventBus.Distributed; -using Fake.UnitOfWork; -using Microsoft.Extensions.Logging; -using Oms.Application.Services.Orders.Issues; - -namespace Oms.Application.Messaging.OrderSideEffects; - -/// -/// 订单旁路副作用事件:事务内写 Outbox,Commit 后投递 RabbitMQ。 -/// -public sealed class OrderSideEffectEventPublisher( - IDistributedEventBus eventBus, - IUnitOfWorkManager unitOfWorkManager, - ILogger logger) : IOrderSideEffectEventPublisher -{ - public async Task PublishLifecycleTrackAppendAsync( - int orderId, - string orderNo, - string trackCode, - string? authorization = null, - CancellationToken cancellationToken = default) - { - if (orderId <= 0 || string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(trackCode)) - { - return; - } - - var @event = new OrderLifecycleTrackAppendIntegrationEvent - { - OrderId = orderId, - OrderNo = orderNo.Trim(), - TrackCode = trackCode.Trim(), - Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim() - }; - - await OmsIntegrationEventPublisher.PublishAsync( - eventBus, unitOfWorkManager, logger, @event, cancellationToken); - } - - public async Task PublishAutoTriggerFireAsync( - int orderId, - AutoTriggerTiming timing, - int operatorUserId, - CancellationToken cancellationToken = default) - { - if (orderId <= 0) - { - return; - } - - var @event = new OrderAutoTriggerFireIntegrationEvent - { - OrderId = orderId, - Timing = timing.ToString(), - OperatorUserId = operatorUserId - }; - - await OmsIntegrationEventPublisher.PublishAsync( - eventBus, unitOfWorkManager, logger, @event, cancellationToken); - } -} diff --git a/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs deleted file mode 100644 index 2545a2e..0000000 --- a/src/Oms.Application/Messaging/ParcelCreate/IOrderParcelCreateEventPublisher.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Oms.Application.Dtos.Orders; -using Oms.Application.Integrations.SmallBagTrack; -using Oms.Domain.OrdersAggregate.Entities; - -namespace Oms.Application.Messaging.ParcelCreate; - -/// -/// 创单后置副作用:发布到 Fake.EventBus.RabbitMQ;发布失败时回落同步执行。 -/// -public interface IOrderParcelCreateEventPublisher -{ - /// - /// 轨迹订单 + 创建成功轨迹(A0101)合并为一个事件。 - /// - Task PublishTrackUpsertAsync( - Order order, - SmallBagTrackUpsertRequest request, - int? customerId, - CancellationToken cancellationToken = default); - - Task PublishLabelGenerateAsync( - Order order, - int operatorUserId = 0, - CancellationToken cancellationToken = default); - - Task PublishCustoms9610UpsertAsync( - Order order, - string? platformCode = null, - CancellationToken cancellationToken = default); - - /// - /// 发布揽收挂接事件。返回 true 表示已入队;false 表示已同步回落执行(或参数不足未执行)。 - /// - Task PublishPickupAttachAsync( - OrderParcelCreateInput input, - int orderId, - CancellationToken cancellationToken = default); - - /// - /// 同步执行揽收挂接(用于创单 HTTP 需要回写 PickupNo 的场景)。 - /// - Task ExecutePickupAttachAsync( - OrderParcelCreateInput input, - int orderId, - CancellationToken cancellationToken = default); -} diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs deleted file mode 100644 index cf35e80..0000000 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCreateEventPublisher.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Fake.EventBus.Distributed; -using Fake.UnitOfWork; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Oms.Application.Dtos.Orders; -using Oms.Application.Integrations.SmallBagTrack; -using Oms.Application.Services.Orders; -using Oms.Domain.OrdersAggregate.Consts; -using Oms.Domain.OrdersAggregate.Entities; - -namespace Oms.Application.Messaging.ParcelCreate; - -/// -/// 创单后置事件发布器:事务内写 Outbox(Fake.EventBus.IntegrationEventLog),Commit 后投递 RabbitMQ。 -/// 揽收 Publish 失败时由调用方同步 ExecutePickupAttachAsync 回落。 -/// -public sealed class OrderParcelCreateEventPublisher( - IDistributedEventBus eventBus, - IUnitOfWorkManager unitOfWorkManager, - IServiceScopeFactory scopeFactory, - ILogger logger) : IOrderParcelCreateEventPublisher -{ - public async Task PublishTrackUpsertAsync( - Order order, - SmallBagTrackUpsertRequest request, - int? customerId, - CancellationToken cancellationToken = default) - { - var @event = new OrderParcelTrackUpsertIntegrationEvent - { - OrderId = order.Id, - CustomerId = customerId, - PayloadJson = JsonConvert.SerializeObject(request), - AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess, - Authorization = request.Authorization - }; - - await OmsIntegrationEventPublisher.PublishAsync( - eventBus, unitOfWorkManager, logger, @event, cancellationToken); - } - - public async Task PublishLabelGenerateAsync( - Order order, - int operatorUserId = 0, - CancellationToken cancellationToken = default) - { - var @event = new OrderParcelLabelGenerateIntegrationEvent - { - OrderId = order.Id, - OperatorUserId = operatorUserId - }; - - await OmsIntegrationEventPublisher.PublishAsync( - eventBus, unitOfWorkManager, logger, @event, cancellationToken); - } - - public async Task PublishCustoms9610UpsertAsync( - Order order, - string? platformCode = null, - CancellationToken cancellationToken = default) - { - var @event = new OrderParcelCustoms9610UpsertIntegrationEvent - { - OrderId = order.Id, - PlatformCode = platformCode - }; - - await OmsIntegrationEventPublisher.PublishAsync( - eventBus, unitOfWorkManager, logger, @event, cancellationToken); - } - - public async Task PublishPickupAttachAsync( - OrderParcelCreateInput input, - int orderId, - CancellationToken cancellationToken = default) - { - var @event = BuildPickupEvent(input, orderId); - if (@event == null) - { - return false; - } - - return await OmsIntegrationEventPublisher.TryPublishAsync( - eventBus, unitOfWorkManager, logger, @event, cancellationToken); - } - - public async Task ExecutePickupAttachAsync( - OrderParcelCreateInput input, - int orderId, - CancellationToken cancellationToken = default) - { - if (orderId <= 0 - || input.SenderAddressId is not > 0 - || input.ExpectPickupTime is null - || input.ExpectPickupTime == default - || input.WarehouseId is not > 0) - { - return null; - } - - using var scope = scopeFactory.CreateScope(); - var pickupService = scope.ServiceProvider.GetRequiredService(); - return await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput - { - CustomerId = input.CustomerId, - SenderAddressId = input.SenderAddressId.Value, - Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection), - WarehouseId = input.WarehouseId.Value, - CarrierCode = input.CarrierCode, - CarrierService = input.CarrierService, - ExpectPickupTime = input.ExpectPickupTime.Value, - OrderIds = [orderId], - Remark = null - }); - } - - private static OrderParcelPickupAttachIntegrationEvent? BuildPickupEvent( - OrderParcelCreateInput input, - int orderId) - { - if (orderId <= 0 - || input.SenderAddressId is not > 0 - || input.ExpectPickupTime is null - || input.ExpectPickupTime == default - || input.WarehouseId is not > 0) - { - return null; - } - - return new OrderParcelPickupAttachIntegrationEvent - { - OrderId = orderId, - CustomerId = input.CustomerId, - SenderAddressId = input.SenderAddressId.Value, - WarehouseId = input.WarehouseId.Value, - PickupDirection = input.PickupDirection, - CarrierCode = input.CarrierCode, - CarrierService = input.CarrierService, - ExpectPickupTime = input.ExpectPickupTime.Value - }; - } -} diff --git a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs index 8131be4..6772161 100644 --- a/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs +++ b/src/Oms.Application/OrderBatchMessagingServiceExtensions.cs @@ -1,8 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Oms.Application.Messaging; -using Oms.Application.Messaging.OrderSideEffects; -using Oms.Application.Messaging.ParcelCreate; namespace Oms.Application; @@ -11,10 +9,7 @@ public static class OrderBatchMessagingServiceExtensions public static IServiceCollection AddOmsOrderBatchMessaging(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection(OrderBatchRabbitMqOptions.SectionName)); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); // 消费端由 Fake.EventBus.RabbitMQ(RabbitMqEventBus HostedService)托管 services.AddHostedService(); return services; diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs index 1c0ef56..4008592 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.DependencyInjection; +using Fake.EventBus.Distributed; using Fake.ObjectStorage; using Newtonsoft.Json; using Oms.Application.Dtos.Orders; @@ -24,7 +25,7 @@ public sealed class OrderBatchActionExecutor( IObjectStorage fileStorage, ISmallBagTrackOrderClient smallBagTrackOrderClient, ISmallBagTrackUpsertRequestBuilder smallBagTrackUpsertRequestBuilder, - IOrderParcelCreateEventPublisher parcelCreateEventPublisher, + IDistributedEventBus eventBus, OrderDownstreamCredentialResolver downstreamCredentialResolver, IItfxForwardClient itfxForwardClient, OrderFileManager orderFileManager, @@ -252,11 +253,14 @@ public sealed class OrderBatchActionExecutor( trackRequest.Authorization = authorization.Trim(); } - await parcelCreateEventPublisher.PublishTrackUpsertAsync( - order, - trackRequest, - customerId: order.CustomerId, - cancellationToken); + await eventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent + { + OrderId = order.Id, + CustomerId = order.CustomerId, + PayloadJson = JsonConvert.SerializeObject(trackRequest), + AppendInitialTrackOnSuccess = trackRequest.AppendInitialTrackOnSuccess, + Authorization = trackRequest.Authorization + }, cancellationToken); } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs index 3cac21b..493334e 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobCoordinator.cs @@ -1,6 +1,6 @@ using Fake.Application; using Fake.DependencyInjection; -using Fake.UnitOfWork; +using Fake.EventBus.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; @@ -29,9 +29,8 @@ public sealed class OrderBatchJobCoordinator( ICountryRepository countryRepository, IServiceChannelRepository serviceChannelRepository, IObjectStorage fileStorage, - IOrderBatchJobPublisher publisher, + IDistributedEventBus eventBus, OrderBatchJobProcessor batchJobProcessor, - IUnitOfWorkManager unitOfWorkManager, IOptions batchOptions, ILogger logger) : IScopedDependency { @@ -44,7 +43,7 @@ public sealed class OrderBatchJobCoordinator( string? authorization = null, CancellationToken cancellationToken = default) { - if (!publisher.IsEnabled) + if (!batchOptions.Value.Enabled) { throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置"); } @@ -78,12 +77,6 @@ public sealed class OrderBatchJobCoordinator( } } - // 先打开 EF Outbox 上下文,尽量让环境事务由 EF 占连接,避免 SqlSugar 先开事务后 Outbox 征用失败。 - if (OmsOutboxUnitOfWork.IsAmbientTransactional(unitOfWorkManager)) - { - await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); - } - var now = DateTime.Now; var job = new OrderBatchJob { @@ -161,7 +154,7 @@ public sealed class OrderBatchJobCoordinator( MessageId = item.MessageId ?? $"{job.Id}:{item.OrderId}:{action}", }; - await publisher.PublishAsync(message, cancellationToken); + await eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message), cancellationToken); } logger.LogInformation( @@ -181,7 +174,7 @@ public sealed class OrderBatchJobCoordinator( int operatorUserId, CancellationToken cancellationToken = default) { - if (!publisher.IsEnabled) + if (!batchOptions.Value.Enabled) { throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置"); } @@ -194,8 +187,9 @@ public sealed class OrderBatchJobCoordinator( for (var index = 0; index < items.Count; index++) { - await publisher.PublishAsync( - BuildPayloadMessage(job, items[index], itemsInput[index], input.CustomerId, operatorUserId), + await eventBus.PublishAsync( + OrderBatchJobIntegrationEvent.FromMessage( + BuildPayloadMessage(job, items[index], itemsInput[index], input.CustomerId, operatorUserId)), cancellationToken); } @@ -383,7 +377,7 @@ public sealed class OrderBatchJobCoordinator( public async Task PublishTrackUpsertAsync(SmallBagTrackUpsertRequest request, int orderId, int? customerId) { - if (!publisher.IsEnabled) + if (!batchOptions.Value.Enabled) { logger.LogDebug("RabbitMQ disabled, track upsert will use direct call. orderId={OrderId}", orderId); return false; @@ -401,7 +395,7 @@ public sealed class OrderBatchJobCoordinator( MessageId = $"track:{orderId}:{Guid.NewGuid():N}", }; - await publisher.PublishAsync(message); + await eventBus.PublishAsync(OrderBatchJobIntegrationEvent.FromMessage(message)); return true; } @@ -437,7 +431,7 @@ public sealed class OrderBatchJobCoordinator( int operatorUserId, CancellationToken cancellationToken = default) { - if (!publisher.IsEnabled) + if (!batchOptions.Value.Enabled) { throw new BusinessException("订单批量队列未启用,请检查 RabbitMQ 配置"); } @@ -472,7 +466,9 @@ public sealed class OrderBatchJobCoordinator( item.UpdateUserId = operatorUserId; await batchJobRepository.UpdateItemAsync(item, cancellationToken); - await publisher.PublishAsync(BuildMessageFromJobItem(job, item, operatorUserId), cancellationToken); + await eventBus.PublishAsync( + OrderBatchJobIntegrationEvent.FromMessage(BuildMessageFromJobItem(job, item, operatorUserId)), + cancellationToken); } job.Status = OrderBatchJobStatusCodes.Running; diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index 34d536e..fec95be 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -1,4 +1,5 @@ using Fake.DependencyInjection; +using Fake.EventBus.Distributed; using Microsoft.Extensions.Logging; using Oms.Application.Messaging.OrderSideEffects; using Oms.Domain.ChannelsAggregate.Consts; @@ -16,7 +17,7 @@ public class AutoTriggerRuntimeService( OrderAutoTriggerService orderAutoTriggerService, OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, - IOrderSideEffectEventPublisher sideEffectEventPublisher, + IDistributedEventBus eventBus, ILogger logger) : IScopedDependency { private const string InterceptOperation = "拦截"; @@ -27,7 +28,12 @@ public class AutoTriggerRuntimeService( public Task EnqueueFireAsync(int orderId, AutoTriggerTiming timing, int operatorUserId) => orderId <= 0 ? Task.CompletedTask - : sideEffectEventPublisher.PublishAutoTriggerFireAsync(orderId, timing, operatorUserId); + : eventBus.PublishAsync(new OrderAutoTriggerFireIntegrationEvent + { + OrderId = orderId, + Timing = timing.ToString(), + OperatorUserId = operatorUserId + }); public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId) { diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs b/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs index 6649f53..ce3b856 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchCreate.cs @@ -1,4 +1,6 @@ using Fake.Application; +using Fake.EventBus.Distributed; +using Microsoft.Extensions.Options; using Newtonsoft.Json; using Oms.Application.Dtos.Orders; using Oms.Application.Messaging; @@ -10,8 +12,11 @@ namespace Oms.Application.Services.Orders; public partial class OrderService { - private IOrderBatchJobPublisher BatchJobPublisher => - LazyServiceProvider.GetRequiredService(); + private IDistributedEventBus EventBus => + LazyServiceProvider.GetRequiredService(); + + private bool BatchQueueEnabled => + LazyServiceProvider.GetRequiredService>().Value.Enabled; /// /// 批量 Worker 导入单行 Excel 数据(内部方法,勿暴露为 HTTP API)。 diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 40f81fa..6728828 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -554,7 +554,7 @@ public partial class OrderService var batchInput = await BuildImportBatchJobInputAsync(input); var operatorUserId = CurrentUser.GetId(); - if (BatchJobPublisher.IsEnabled) + if (BatchQueueEnabled) { var job = await BatchJobCoordinator.CreatePayloadJobAsync(batchInput, operatorUserId); return new OrderUploadOutput { JobId = job.JobId, Async = true }; diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 56ef739..9199c16 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -41,7 +41,6 @@ public partial class OrderService private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; internal const string ForecastBillingProcessingReason = "预报计费/排货处理中"; - private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报"; private static readonly ConcurrentDictionary InboundBillingInFlight = new(); @@ -100,11 +99,15 @@ public partial class OrderService }; } + /// + /// /order/parcel/create 的实际步骤。调用方已开启事务;这里抛出的异常会回滚订单和已写入的 Outbox。 + /// private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, int? operatorUserId = null, ParcelCreateContext? createContext = null) { + // 1. 幂等:同一 RequestId 直接返回已有订单,不再建单、不再发事件。 if (!string.IsNullOrWhiteSpace(input.RequestId)) { var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId); @@ -118,6 +121,7 @@ public partial class OrderService NormalizeParcelCreateInput(input); createContext ??= BuildParcelCreateContext(input); + // 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。 var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId); input.CustomerId = sessionCustomer.Id; @@ -151,112 +155,74 @@ public partial class OrderService country.CountryCode, country.ChineseName); + // 3. 落库订单、件重尺和收发件等子表。此后才有 OrderId,才能写 Outbox。 var serviceNo = await ResolveServiceNoAsync(); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); - Order? created = null; - try + var order = BuildDraftOrderEntity( + input, + product, + country.CountryCode, + country.ChineseName, + snapshotBinding, + serviceNo, + createContext); + order = await OrderRepository.InsertAsync(order); + + var weights = BuildInitialWeightRows(order.Id, input); + await SaveParcelChildrenAsync(order, input, weights, country); + + // 4. 保存草稿:不做预报计费。9610、上门揽收与订单一起提交。 + if (input.SaveAsDraft) { - var order = BuildDraftOrderEntity( - input, - product, - country.CountryCode, - country.ChineseName, - snapshotBinding, - serviceNo, - createContext); - order = await OrderRepository.InsertAsync(order); - created = order; - - var weights = BuildInitialWeightRows(order.Id, input); - await SaveParcelChildrenAsync(order, input, weights, country); - - // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 - if (input.SaveAsDraft) + await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent { - var draftOutput = await BuildParcelCreateOutputAsync(order); - await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); - return draftOutput; - } - - // 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。 - order.IsRemote = await IsRemoteHitAsync( - product, - snapshotBinding.Rules, - country.CountryCode, - input.Receiver?.Province, - input.Receiver?.City, - input.Receiver?.ZipCode) ? (byte)1 : (byte)0; - order.OrderStatus = OrderStatusCodes.Draft; - order.PredeclareFailReason = ForecastBillingProcessingReason; - order.AllocationPendingReason = string.Empty; - order.UpdateTime = DateTime.Now; - await OrderRepository.UpdateAsync(order); - - var output = await BuildParcelCreateOutputAsync(order); - await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); - return output; - } - catch (BusinessException) - { - await TryRollbackFailedParcelCreateAsync(created); - throw; - } - catch (Exception ex) - { - // 创建失败不落库草稿:回滚已插入订单,由前端让客户选择继续修改或主动保存草稿。 - await TryRollbackFailedParcelCreateAsync(created); - - var cleanReason = StripDraftSavedMarker(ex.Message); - if (string.IsNullOrWhiteSpace(cleanReason)) - { - cleanReason = "预报未完成"; - } - - throw new BusinessException(cleanReason); - } - } - - private async Task TryRollbackFailedParcelCreateAsync(Order? created) - { - if (created == null) - { - return; + OrderId = order.Id, + PlatformCode = input.PlatformCode + }); + var draftOutput = await BuildParcelCreateOutputAsync(order); + await TryAttachPickupAfterParcelCreateAsync(input, draftOutput); + return draftOutput; } - try - { - await RollbackFailedParcelCreateAsync(created); - } - catch - { - // 回滚失败不覆盖原始业务错误;半成品订单需运维清理 - } - } - - /// - /// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。 - /// - private async Task RollbackFailedParcelCreateAsync(Order order) - { - try - { - var writer = LazyServiceProvider.GetRequiredService(); - // 强制清掉可能已写入的 pending 9610(ExportCustomsMode 临时置空触发删除逻辑) - var mode = order.ExportCustomsMode; - order.ExportCustomsMode = null; - await writer.UpsertFromOrderAsync(order); - order.ExportCustomsMode = mode; - } - catch - { - // ignore - } - - order.OrderStatus = OrderStatusCodes.Deleted; - order.PredeclareFailReason = string.Empty; + // 5. 确认下单:偏远标记当场算完;计费重/应收/排货留给预报任务。 + // 状态先停在草稿 +「预报计费/排货处理中」,接口即可返回。 + order.IsRemote = await IsRemoteHitAsync( + product, + snapshotBinding.Rules, + country.CountryCode, + input.Receiver?.Province, + input.Receiver?.City, + input.Receiver?.ZipCode) ? (byte)1 : (byte)0; + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = ForecastBillingProcessingReason; + order.AllocationPendingReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await OrderRepository.DeleteAsync(order); + + // 6. 与订单同一事务写入 Outbox。Commit 后由 Fake 投递;任一条失败则整单回滚。 + // 轨迹不在此列:要等预报消费端排货拿到 ChannelCode 再发。 + var authorization = CaptureRequestAuthorization(); + await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent + { + OrderId = order.Id, + PlatformCode = input.PlatformCode + }); + await EnqueueForecastBillingAsync(order, input.CouponInstanceId, operatorId, authorization); + await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent + { + OrderId = order.Id, + OperatorUserId = operatorId + }); + await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId); + + var output = await BuildParcelCreateOutputAsync(order); + output.ForecastBillingQueued = string.Equals( + order.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal); + // 揽收失败只回写 PickupCreateError,不让整单回滚。 + await TryAttachPickupAfterParcelCreateAsync(input, output); + return output; } private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null) @@ -2330,28 +2296,13 @@ public partial class OrderService var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); var authorization = CaptureRequestAuthorization(); - var publisher = LazyServiceProvider.GetRequiredService(); - if (!publisher.IsEnabled) + await EnqueueForecastBillingAsync(order, order.CouponInstanceId, operatorId, authorization); + await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent { - var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload - { - CouponInstanceId = order.CouponInstanceId, - Authorization = authorization - }); - await ProcessForecastBillingForBatchAsync( - order.Id, - payloadJson, - operatorId, - authorization: authorization); - } - else - { - var orderId = order.Id; - var couponInstanceId = order.CouponInstanceId; - await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId, authorization); - } - - await ScheduleForecastSideEffectsAsync(order.Id, operatorId); + OrderId = order.Id, + OperatorUserId = operatorId + }); + await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId); // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); @@ -2846,7 +2797,7 @@ public partial class OrderService // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode)) { - await ScheduleTrackAfterAllocationAsync(order.Id, trackAuthorization); + await PublishForecastTrackAsync(order.Id, trackAuthorization); } var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); @@ -2872,154 +2823,6 @@ public partial class OrderService public string? Authorization { get; set; } } - /// - /// 创单事务提交后,9610、预报计费、揽收、面单、自动触发各自提交。 - /// 轨迹订单等排货成功后再创建。 - /// - private async Task ScheduleCreateSideEffectsAsync( - int orderId, - OrderParcelCreateInput input, - OrderParcelCreateOutput output, - bool includeForecast, - int operatorUserId) - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate"); - var platformCode = input.PlatformCode; - var couponInstanceId = input.CouponInstanceId; - // 事务提交后 HttpContext 已不可用,这里先捕获登录态供轨迹网关使用。 - var authorization = CaptureRequestAuthorization(); - - await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => - { - try - { - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => - sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); - - var forecastQueued = true; - if (includeForecast) - { - forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => - { - var service = sp.GetRequiredService(); - await service.EnqueueForecastBillingByIdAsync( - orderId, - couponInstanceId, - operatorUserId, - authorization); - }); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => - sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => - sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); - } - - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => - sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output)); - - if (includeForecast && !forecastQueued) - { - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => - sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); - } - } - catch (Exception ex) - { - logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId); - } - }); - } - - private async Task ScheduleForecastEnqueueAsync( - int orderId, - int? couponInstanceId, - int operatorUserId, - string? authorization) - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); - await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => - OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp => - sp.GetRequiredService().EnqueueForecastBillingByIdAsync( - orderId, - couponInstanceId, - operatorUserId, - authorization))); - } - - /// - /// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。 - /// - private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId) - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); - await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => - { - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => - sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => - sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); - }); - } - - /// - /// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。 - /// - private async Task ScheduleTrackAfterAllocationAsync(int orderId, string? authorization) - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); - await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => - OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId, authorization))); - } - - private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); - } - - private async Task EnqueueForecastBillingByIdAsync( - int orderId, - int? couponInstanceId, - int operatorUserId, - string? authorization) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId, authorization); - } - - private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || !string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) - { - return; - } - - await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); - } - private async Task PublishForecastTrackAsync(int orderId, string? authorization = null) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); @@ -3036,41 +2839,26 @@ public partial class OrderService var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); request.AppendInitialTrackOnSuccess = true; ApplyTrackAuthorization(request, authorization); - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); - } - - private async Task PublishForecastLabelAsync(int orderId, int operatorUserId) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + await EventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent { - return; - } - - await ParcelCreateEventPublisher.PublishLabelGenerateAsync(order, operatorUserId); - } - - private async Task PublishForecastAutoTriggerAsync(int orderId, int operatorUserId) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - await AutoTriggerRuntimeService.EnqueueFireAsync(orderId, AutoTriggerTiming.OrderCreate, operatorUserId); + OrderId = order.Id, + CustomerId = customer?.Id, + PayloadJson = JsonConvert.SerializeObject(request), + AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess, + Authorization = request.Authorization + }); } + /// + /// 预报计费入队。队列开着写 ForecastBilling Outbox;关着则在当前事务里同步跑同一套计费。 + /// Authorization 放进任务载荷,供排货成功后转发轨迹网关。 + /// private async Task EnqueueForecastBillingAsync( Order order, int? couponInstanceId, int operatorUserId, string? authorization = null) { - var logger = LazyServiceProvider.GetRequiredService() - .CreateLogger("Oms.ForecastBilling"); authorization ??= CaptureRequestAuthorization(); var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload { @@ -3078,62 +2866,27 @@ public partial class OrderService Authorization = authorization }); - try + if (!BatchQueueEnabled) { - var publisher = LazyServiceProvider.GetRequiredService(); - if (!publisher.IsEnabled) - { - try - { - await ProcessForecastBillingForBatchAsync( - order.Id, - payloadJson, - operatorUserId, - authorization: authorization); - } - catch (Exception ex) - { - logger.LogError( - ex, - "预报计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}", - order.Id, - order.ServiceNo); - await MarkParcelDraftFailedAsync( - order, - TrimPendingReason($"预报计费失败:{ex.Message}")); - } - - return; - } - - var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}"; - await BatchJobCoordinator.CreateAndEnqueueAsync( - new OrderBatchJobCreateInput - { - Action = OrderBatchActionCodes.ForecastBilling, - OrderIds = [order.Id], - RequestId = requestId, - PayloadJson = payloadJson - }, - operatorUserId, - authorization); - } - catch (Exception ex) - { - logger.LogError( - ex, - "预报计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}", + await ProcessForecastBillingForBatchAsync( order.Id, - order.ServiceNo); - try - { - await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); - } - catch - { - // ignore - } + payloadJson, + operatorUserId, + authorization: authorization); + return; } + + var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}"; + await BatchJobCoordinator.CreateAndEnqueueAsync( + new OrderBatchJobCreateInput + { + Action = OrderBatchActionCodes.ForecastBilling, + OrderIds = [order.Id], + RequestId = requestId, + PayloadJson = payloadJson + }, + operatorUserId, + authorization); } private async Task EnqueueInboundBillingAsync(Order order) @@ -3142,8 +2895,7 @@ public partial class OrderService .CreateLogger("Oms.InboundBilling"); try { - var publisher = LazyServiceProvider.GetRequiredService(); - if (!publisher.IsEnabled) + if (!BatchQueueEnabled) { // 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成) try @@ -3397,12 +3149,6 @@ public partial class OrderService }; } - private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher => - LazyServiceProvider.GetRequiredService(); - - private IOrderSideEffectEventPublisher SideEffectEventPublisher => - LazyServiceProvider.GetRequiredService(); - /// /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 /// @@ -3415,7 +3161,14 @@ public partial class OrderService var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); request.AppendInitialTrackOnSuccess = appendInitialTrack; ApplyTrackAuthorization(request); - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + await EventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent + { + OrderId = order.Id, + CustomerId = customer?.Id, + PayloadJson = JsonConvert.SerializeObject(request), + AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess, + Authorization = request.Authorization + }); } private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null) @@ -3486,11 +3239,13 @@ public partial class OrderService .Authorization .ToString(); - await SideEffectEventPublisher.PublishLifecycleTrackAppendAsync( - order.Id, - order.ServiceNo, - trackCode, - string.IsNullOrWhiteSpace(authorization) ? null : authorization); + await EventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent + { + OrderId = order.Id, + OrderNo = order.ServiceNo, + TrackCode = trackCode, + Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization + }); } /// diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 1932297..7022a57 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -603,34 +603,20 @@ public partial class OrderService : ApplicationService } /// - /// 创建订单包裹(PC/H5 确认下单或保存草稿)。 + /// 创建订单包裹(PC/H5 确认下单或保存草稿)。整段在一个事务里,异常直接抛出并回滚。 /// - /// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 → - /// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。 - /// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回; - /// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。 + /// 【本请求内】RequestId 幂等 → 校验 → 黑名单 → 下单前拦截规则 → 落库订单与子表。 + /// 保存草稿只再写 9610 与上门揽收;确认下单额外标记「预报处理中」,并写入预报计费、面单、自动触发。 /// /// - /// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】 - /// 1) 9610 订单 Upsert(customs-9610)— 草稿与确认下单都会投; - /// 2) 预报计费/排货任务(forecast-billing 批量任务)— 仅确认下单; - /// 队列关闭时在后台同步跑同一套计费逻辑; - /// 3) 系统面单 PDF(label-generate)— 仅确认下单; - /// 4) 下单自动触发(OrderCreate:警告/问题件等,不含拦截)— 仅确认下单; - /// 5) 上门揽收挂载(pickup-attach)— 仅上门揽收且地址/时间/仓库齐全时; - /// 投递失败则同步回落建揽收单。 - /// - /// - /// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。 - /// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert); - /// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发; - /// 排货失败不阻断预报,也不建轨迹(可页面补做)。 + /// 【同一事务的 Outbox,Commit 后才投递】9610、预报计费、面单、OrderCreate 自动触发、上门揽收。 + /// 队列关闭时预报计费在本事务内同步执行。轨迹不在这里发,等预报消费端排货成功后再发。 /// /// /// 入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。 /// - /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队; - /// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。 + /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已入队; + /// 上门揽收入队成功时 PickupQueued=true,失败只写 PickupCreateError,不回滚订单。 /// [HttpPost("/order/parcel/create")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] @@ -680,6 +666,9 @@ public partial class OrderService : ApplicationService } } + /// + /// 仅上门揽收。地址、期望时间和仓库齐全才入队;发布失败写入返回值,不抛回创单事务。 + /// private async Task TryAttachPickupAfterParcelCreateAsync( OrderParcelCreateInput input, OrderParcelCreateOutput output) @@ -715,27 +704,20 @@ public partial class OrderService : ApplicationService return; } - var publisher = LazyServiceProvider.GetRequiredService(); try { - // 优先入 Fake.EventBus;发布失败则同步回落建揽收并回写单号。 - var queued = await publisher.PublishPickupAttachAsync(input, output.OrderId); - if (queued) + await EventBus.PublishAsync(new OrderParcelPickupAttachIntegrationEvent { - output.PickupQueued = true; - return; - } - - var pickup = await publisher.ExecutePickupAttachAsync(input, output.OrderId); - if (pickup == null) - { - output.PickupCreateError = "揽收单创建失败"; - return; - } - - output.PickupNo = pickup.PickupNo; - output.PickupOrderId = pickup.Id; - output.CarrierWaybillNo = pickup.CarrierWaybillNo; + OrderId = output.OrderId, + CustomerId = input.CustomerId, + SenderAddressId = input.SenderAddressId.Value, + WarehouseId = input.WarehouseId.Value, + PickupDirection = input.PickupDirection, + CarrierCode = input.CarrierCode, + CarrierService = input.CarrierService, + ExpectPickupTime = input.ExpectPickupTime.Value + }); + output.PickupQueued = true; } catch (Exception ex) { @@ -762,7 +744,7 @@ public partial class OrderService : ApplicationService throw new BusinessException($"单次批量创建不能超过{ParcelBatchMaxCount}条"); } - if (BatchJobPublisher.IsEnabled) + if (BatchQueueEnabled) { return await BatchCreateParcelViaQueueAsync(input); } diff --git a/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs b/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs index da907e3..eeac041 100644 --- a/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs +++ b/src/Oms.Tests/Messaging/OrderBatchJobOutboxPublisherTests.cs @@ -6,8 +6,6 @@ using Fake.Testing; using Fake.UnitOfWork; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Oms.Application.Messaging; using Oms.Domain.OrdersAggregate.Consts; using Oms.Tests.Messaging.Fakes; @@ -18,8 +16,8 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools _dbProvider; - private readonly OrderBatchJobPublisher _publisher; protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options) { @@ -29,13 +27,9 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools(); + _eventBus = ServiceProvider.GetRequiredService(); _physicalBus = ServiceProvider.GetRequiredService(); _dbProvider = ServiceProvider.GetRequiredService>(); - _publisher = new OrderBatchJobPublisher( - ServiceProvider.GetRequiredService(), - _uowManager, - Options.Create(new OrderBatchRabbitMqOptions { Enabled = true }), - NullLogger.Instance); } [Fact] @@ -56,7 +50,7 @@ public class OrderBatchJobOutboxPublisherTests : ApplicationTestWithTools(), - _uowManager, - Options.Create(new OrderBatchRabbitMqOptions { Enabled = false }), - NullLogger.Instance); - - var act = () => disabled.PublishAsync(new OrderBatchJobMessage - { - Action = OrderBatchActionCodes.ForecastBilling, - OrderId = 1, - MessageId = "x" - }); - - await act.Should().ThrowAsync() - .WithMessage("*disabled*"); - } } diff --git a/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs b/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs index f4d036e..6cda93d 100644 --- a/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs +++ b/src/Oms.Tests/Messaging/OrderParcelCreateOutboxPublisherTests.cs @@ -6,7 +6,7 @@ using Fake.Testing; using Fake.UnitOfWork; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Messaging; using Oms.Application.Messaging.ParcelCreate; @@ -22,7 +22,6 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools _dbProvider; - private readonly OrderParcelCreateEventPublisher _publisher; protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options) { @@ -36,11 +35,6 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools(); _outbox = ServiceProvider.GetRequiredService(); _dbProvider = ServiceProvider.GetRequiredService>(); - _publisher = new OrderParcelCreateEventPublisher( - _distributedEventBus, - _uowManager, - ServiceProvider.GetRequiredService(), - NullLogger.Instance); } [Fact] @@ -58,7 +52,11 @@ public class OrderParcelCreateOutboxPublisherTests : ApplicationTestWithTools _distributedEventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent + { + OrderId = 404, + PlatformCode = "shopee" + }); - _physicalBus.Published.Should().ContainSingle() - .Which.Should().BeOfType() - .Which.OrderId.Should().Be(404); + await act.Should().ThrowAsync(); } } diff --git a/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs b/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs index 8f3abf8..84fd172 100644 --- a/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs +++ b/src/Oms.Tests/Messaging/OrderSideEffectOutboxPublisherTests.cs @@ -6,7 +6,6 @@ using Fake.Testing; using Fake.UnitOfWork; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; using Oms.Application.Messaging; using Oms.Application.Messaging.OrderSideEffects; using Oms.Application.Services.Orders.Issues; @@ -20,7 +19,6 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools _dbProvider; - private readonly OrderSideEffectEventPublisher _publisher; protected override void SetApplicationCreationOptions(FakeApplicationCreationOptions options) { @@ -33,10 +31,6 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools(); _physicalBus = ServiceProvider.GetRequiredService(); _dbProvider = ServiceProvider.GetRequiredService>(); - _publisher = new OrderSideEffectEventPublisher( - _distributedEventBus, - _uowManager, - NullLogger.Instance); } [Fact] @@ -47,8 +41,18 @@ public class OrderSideEffectOutboxPublisherTests : ApplicationTestWithTools _countryRepository = new(MockBehavior.Loose); private readonly Mock _serviceChannelRepository = new(MockBehavior.Loose); private readonly Mock _fileStorage = new(MockBehavior.Loose); - private readonly Mock _publisher = new(MockBehavior.Strict); + private readonly Mock _eventBus = new(MockBehavior.Strict); private readonly OrderBatchJobCoordinator _coordinator; public OrderBatchJobCoordinatorGovernanceTests() { - _publisher.SetupGet(x => x.IsEnabled).Returns(true); - var batchOptions = Options.Create(new OrderBatchRabbitMqOptions()); + var batchOptions = Options.Create(new OrderBatchRabbitMqOptions { Enabled = true }); var actionExecutor = new Mock(MockBehavior.Loose); var processor = new OrderBatchJobProcessor( _batchJobRepository.Object, @@ -37,17 +36,14 @@ public class OrderBatchJobCoordinatorGovernanceTests actionExecutor.Object, batchOptions, NullLogger.Instance); - var unitOfWorkManager = new Mock(MockBehavior.Loose); - unitOfWorkManager.SetupGet(x => x.Current).Returns((IUnitOfWork?)null); _coordinator = new OrderBatchJobCoordinator( _batchJobRepository.Object, _orderRepository.Object, _countryRepository.Object, _serviceChannelRepository.Object, _fileStorage.Object, - _publisher.Object, + _eventBus.Object, processor, - unitOfWorkManager.Object, batchOptions, NullLogger.Instance); } @@ -71,8 +67,8 @@ public class OrderBatchJobCoordinatorGovernanceTests _batchJobRepository .Setup(x => x.UpdateJobAsync(job, It.IsAny())) .Returns(Task.CompletedTask); - _publisher - .Setup(x => x.PublishAsync(It.IsAny(), It.IsAny())) + _eventBus + .Setup(x => x.PublishAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var output = await _coordinator.RetryFailedAsync(job.Id, null, 99); @@ -83,9 +79,9 @@ public class OrderBatchJobCoordinatorGovernanceTests failedItem.RetryCount.Should().Be(0); failedItem.FailReason.Should().BeNull(); job.Status.Should().Be(OrderBatchJobStatusCodes.Running); - _publisher.Verify( + _eventBus.Verify( x => x.PublishAsync( - It.Is(m => m.JobItemId == failedItem.Id), + It.Is(m => m.JobItemId == failedItem.Id), It.IsAny()), Times.Once); } -- 2.45.2 From b6f0d3ac13dbe71fb5ef5522f251f801b9674253 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Fri, 16 Oct 2026 14:59:14 +0800 Subject: [PATCH 04/15] =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b45eadd..ddcc2b7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,6 @@ - 8.0.5.27 + 8.0.5.28 -- 2.45.2 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 05/15] =?UTF-8?q?fix:=20TikTok=20=E6=8B=89=E5=8D=95?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20shop=5Fcipher=20=E4=B8=8E=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E7=AD=BE=E5=90=8D=EF=BC=8C=E5=B9=B6=E6=8C=89=E7=8A=B6?= =?UTF-8?q?=E6=80=81=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, -- 2.45.2 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 06/15] =?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); + } } -- 2.45.2 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 07/15] =?UTF-8?q?fix:=20TikTok=20=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E9=9D=A2=E5=8D=95=E6=94=B9=E4=B8=BA=E6=8C=89?= =?UTF-8?q?=E5=8C=85=E8=A3=B9=E4=B8=8B=E8=BD=BD=E5=8F=91=E8=B4=A7=E6=96=87?= =?UTF-8?q?=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 -- 2.45.2 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 08/15] =?UTF-8?q?fix:=20TikTok=20=E6=8B=89=E5=8D=95?= =?UTF-8?q?=E6=8C=89=E8=AE=A2=E5=8D=95=E8=AF=A6=E6=83=85=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E6=94=B6=E4=BB=B6=E4=BA=BA=E9=87=91=E9=A2=9D=E8=A1=8C=E5=95=86?= =?UTF-8?q?=E5=93=81=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) -- 2.45.2 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 09/15] =?UTF-8?q?fix:=20TikTok=20=E5=87=86=E5=A4=87?= =?UTF-8?q?=E5=8F=91=E8=B4=A7=E6=94=B9=E4=B8=BA=E7=9C=9F=E5=AE=9E=20shipPa?= =?UTF-8?q?ckage=EF=BC=8C=E5=B9=B6=E5=9B=9E=E5=86=99=E9=9D=A2=E5=8D=95?= =?UTF-8?q?=E7=8A=B6=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, -- 2.45.2 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 10/15] =?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; } } -- 2.45.2 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 11/15] =?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); } } -- 2.45.2 From 4418217b0155888506e9bfed57f420c6820ce577 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Mon, 19 Oct 2026 15:08:05 +0800 Subject: [PATCH 12/15] upgrade 29 --- Directory.Build.props | 4 ++-- Directory.Build.targets | 26 ++++++++++++++++++++ Fake.Local.EventBus.props | 13 ---------- Fake.Local.props | 10 ++++++++ SimpleAdmin.sln | 2 +- src/Oms.Application/Oms.Application.csproj | 28 ++++------------------ src/Oms.Tests/Oms.Tests.csproj | 7 ------ src/SimpleAdmin.Api/SimpleAdmin.Api.csproj | 21 +--------------- 8 files changed, 45 insertions(+), 66 deletions(-) create mode 100644 Directory.Build.targets delete mode 100644 Fake.Local.EventBus.props create mode 100644 Fake.Local.props diff --git a/Directory.Build.props b/Directory.Build.props index ddcc2b7..e7760d3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,9 @@ - + - 8.0.5.28 + 8.0.5.29 diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..34e847f --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,26 @@ + + + + + <_LocalFakeCandidate Include="@(PackageReference -> '$(FakeRepoRoot)\src\%(Identity)\%(Identity).csproj')" /> + <_LocalFakeCandidate Include="@(PackageReference -> '$(FakeRepoRoot)\modules\rbac\%(Identity)\%(Identity).csproj')" /> + <_LocalFakeCandidate Include="@(PackageReference -> '$(FakeRepoRoot)\modules\file-management\%(Identity)\%(Identity).csproj')" /> + + + + + + + + + + <_MissingLocalFake Include="@(_LocalFakeCandidate)" Condition="!Exists('%(Identity)')" /> + + + + diff --git a/Fake.Local.EventBus.props b/Fake.Local.EventBus.props deleted file mode 100644 index 9e0099b..0000000 --- a/Fake.Local.EventBus.props +++ /dev/null @@ -1,13 +0,0 @@ - - - - C:\Users\BSI\RiderProjects\fake2 - - false - - false - - diff --git a/Fake.Local.props b/Fake.Local.props new file mode 100644 index 0000000..8519e29 --- /dev/null +++ b/Fake.Local.props @@ -0,0 +1,10 @@ + + + + C:\Users\BSI\RiderProjects\fake2 + false + + diff --git a/SimpleAdmin.sln b/SimpleAdmin.sln index 858277c..792bf40 100644 --- a/SimpleAdmin.sln +++ b/SimpleAdmin.sln @@ -16,7 +16,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{BE5132AD db.sql = db.sql Directory.Build.props = Directory.Build.props .gitea\workflows\deploy.yml = .gitea\workflows\deploy.yml - Fake.Local.EventBus.props = Fake.Local.EventBus.props + Fake.Local.props = Fake.Local.props EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}" diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 515006d..63f8eff 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -20,6 +20,11 @@ + + + + + @@ -30,29 +35,6 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Oms.Tests/Oms.Tests.csproj b/src/Oms.Tests/Oms.Tests.csproj index 2638958..5dab990 100644 --- a/src/Oms.Tests/Oms.Tests.csproj +++ b/src/Oms.Tests/Oms.Tests.csproj @@ -15,13 +15,6 @@ - - - - - - - diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj index 6b854bb..2686b92 100644 --- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj +++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -17,31 +17,12 @@ - - - - - - - - - - - - - - - - - - - - + -- 2.45.2 From d38963055bf1f4a131b2a48b1d739af96423acd8 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 19 Oct 2026 15:47:39 +0800 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=E6=94=AF=E4=BB=98=E7=94=A8?= =?UTF-8?q?=E5=88=B8/=E4=BC=9A=E5=91=98=E6=8A=98=E6=89=A3=E5=86=99?= =?UTF-8?q?=E5=85=A5=E8=B4=9F=E6=95=B0=E5=BA=94=E6=94=B6=E8=A1=8C=EF=BC=8C?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=BA=94=E6=94=B6=E4=B8=8E=E5=AE=9E=E4=BB=98?= =?UTF-8?q?=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支付成功后写入 coupon_discount / member_discount 负数应收(币种、阶段跟随运费行,记录券码/会员等级) - 营销重算的折前应收排除抵扣行,避免重复扣减;规则公式忽略抵扣行 - 预报/入库计费任务跳过已付款订单,防止重复投递冲掉应收 - 手工新增费用禁止抵扣费用项;费用输出给出抵扣行显示名 - 订单详情返回折前小计、会员折扣、优惠券抵扣、客户实付 - 新增 fee_item 字典迁移 migrate_fee_item_marketing_discount_20261019.sql --- ...e_fee_item_marketing_discount_20261019.sql | 34 +++++++++ .../Dtos/Orders/OrderFeeOutput.cs | 5 ++ .../Dtos/Orders/OrderOutput.cs | 25 +++++++ .../Issues/OrderFormulaFieldResolver.cs | 10 ++- .../Services/Orders/OrderService.Helpers.cs | 8 +++ .../Services/Orders/OrderService.OrderEdit.cs | 5 ++ .../Orders/OrderService.ParcelBilling.cs | 9 ++- .../Services/Orders/OrderService.PayCoupon.cs | 70 +++++++++++++++++++ .../Services/Orders/OrderService.cs | 6 +- .../Orders/ParcelBillingCalculator.cs | 2 + .../Shared/MarketingCouponManager.cs | 6 +- .../Shared/MarketingFeeHelper.cs | 7 +- .../Consts/OrderFeeTypeCodes.cs | 17 +++++ 13 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 sql/migrate_fee_item_marketing_discount_20261019.sql diff --git a/sql/migrate_fee_item_marketing_discount_20261019.sql b/sql/migrate_fee_item_marketing_discount_20261019.sql new file mode 100644 index 0000000..8d3b700 --- /dev/null +++ b/sql/migrate_fee_item_marketing_discount_20261019.sql @@ -0,0 +1,34 @@ +-- 费用项字典:营销抵扣(支付时写入的负数应收行)。可重复执行。 +-- coupon_discount 优惠券抵扣、member_discount 会员折扣;由 /order/pay 自动生成,禁止手工添加。 + +-- 确保字典类型存在 +INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`) +SELECT 'fee_item', '费用项', 0, 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict_type` + WHERE `DictType` = 'fee_item' AND `IsDeleted` = 0 +); + +-- 曾被软删的同 code 项直接恢复 +UPDATE `t_dict` +SET `IsDeleted` = 0, `UpdateTime` = NOW() +WHERE `DictType` = 'fee_item' + AND `DictCode` IN ('coupon_discount', 'member_discount') + AND `IsDeleted` = 1; + +INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`) +SELECT 'fee_item', 900, 'coupon_discount', '优惠券抵扣', 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict` + WHERE `DictType` = 'fee_item' AND `DictCode` = 'coupon_discount' +); + +INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`) +SELECT 'fee_item', 901, 'member_discount', '会员折扣', 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict` + WHERE `DictType` = 'fee_item' AND `DictCode` = 'member_discount' +); diff --git a/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs index bc6a90f..e721c11 100644 --- a/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs @@ -20,6 +20,11 @@ public class OrderFeeOutput /// public string CostItem { get; set; } = string.Empty; + /// + /// 费用项显示名:仅营销抵扣行(优惠券抵扣/会员折扣)由后端给出,其余为空由前端按 fee_item 字典解析。 + /// + public string? CostItemName { get; set; } + /// /// 金额。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index 4eaed1f..c508855 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -368,6 +368,31 @@ public class OrderOutput /// public decimal? ForecastPayableAmount { get; set; } + /// + /// 营销快照:折前应收小计。 + /// + public decimal? ReceivableSubtotal { get; set; } + + /// + /// 营销快照:会员折扣金额(与券互斥)。 + /// + public decimal LevelDiscountAmount { get; set; } + + /// + /// 营销快照:优惠券抵扣金额。 + /// + public decimal CouponDiscountAmount { get; set; } + + /// + /// 营销快照:客户实付金额(支付扣款口径;区别于应付成本)。 + /// + public decimal? ActualPayAmount { get; set; } + + /// + /// 营销快照:使用的优惠券实例 Id。 + /// + public int? CouponInstanceId { get; set; } + /// /// 订单绑定的销售产品规则发布版本。 /// diff --git a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs index 077a4bf..3393c17 100644 --- a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs +++ b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs @@ -29,7 +29,7 @@ public static class OrderFormulaFieldResolver if (fieldRef.Table == "t_order_fee") { - return context.Fees.Any(f => ContainsValue(GetPropertyValue(f, fieldRef.Field), needle)); + return RuleFees(context).Any(f => ContainsValue(GetPropertyValue(f, fieldRef.Field), needle)); } var raw = GetRawValue(fieldRef, context); @@ -255,10 +255,16 @@ public static class OrderFormulaFieldResolver private static object? ResolveFeeField(string field, OrderRuleEvaluationContext context) { - var fee = context.Fees.FirstOrDefault(); + var fee = RuleFees(context).FirstOrDefault(); return fee == null ? null : GetPropertyValue(fee, field); } + /// + /// 规则只看计费产生的费用行,忽略支付写入的营销抵扣负数行。 + /// + private static IEnumerable RuleFees(OrderRuleEvaluationContext context) => + context.Fees.Where(f => !OrderFeeDiscountCostItems.IsDiscount(f.CostItem)); + private static object? GetDeclarationValue(OrderDeclaration declaration, string field) => field switch { diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 6728828..c15f066 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -263,6 +263,11 @@ public partial class OrderService HasInsurance = hasInsurance, ForecastReceivableAmount = order.ForecastReceivableAmount, ForecastPayableAmount = order.ForecastPayableAmount, + ReceivableSubtotal = order.ReceivableSubtotal, + LevelDiscountAmount = order.LevelDiscountAmount, + CouponDiscountAmount = order.CouponDiscountAmount, + ActualPayAmount = order.PayableAmount, + CouponInstanceId = order.CouponInstanceId, SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion) ? null : order.SalesProductRulesVersion.Trim(), @@ -851,6 +856,9 @@ public partial class OrderService Id = x.Id, Type = x.Type, CostItem = x.CostItem, + CostItemName = OrderFeeDiscountCostItems.IsDiscount(x.CostItem) + ? ParcelBillingCalculator.ResolveFeeDisplayName(x.CostItem, null) + : null, Amount = x.Amount, Currency = x.Currency, DownstreamProvider = x.DownstreamProvider, diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 6b43287..1f95706 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -900,6 +900,11 @@ public partial class OrderService throw new BusinessException("费用项不能为空"); } + if (OrderFeeDiscountCostItems.IsDiscount(costItem)) + { + throw new BusinessException("优惠券抵扣、会员折扣由支付自动生成,不能手工添加"); + } + if (input.Amount <= 0) { throw new BusinessException("金额必须大于0"); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 8e2609f..e9afb21 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2449,6 +2449,12 @@ public partial class OrderService { _ = cancellationToken; var order = await GetOrderOrThrowAsync(orderId); + // 已付款订单应收已固化(含营销抵扣行),重复投递的入库计费消息不得重算 + if (order.IsPaid != 0) + { + return; + } + if (string.IsNullOrWhiteSpace(order.ProductCode)) { order.OrderStatus = OrderStatusCodes.PendingAllocation; @@ -2630,7 +2636,8 @@ public partial class OrderService throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); } - if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase) + || order.IsPaid != 0) { return; } diff --git a/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs index 9dbf1ff..e47a62b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs +++ b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Marketing; using Oms.Application.Dtos.Orders; using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; namespace Oms.Application.Services.Orders; @@ -129,6 +130,75 @@ public partial class OrderService } } + /// + /// 支付成功后把会员折扣 / 优惠券抵扣写成负数应收行,使订单应收与实付一致;币种与计费阶段跟随基础运费行。 + /// + private async Task UpsertMarketingDiscountFeesAsync( + Order order, + IEnumerable fees, + MarketingPriceBreakdown breakdown, + string? couponCode) + { + var discountRows = new List(); + if (breakdown.CouponDiscountAmount <= 0 && breakdown.LevelDiscountAmount <= 0) + { + return; + } + + var receivable = fees + .Where(x => x.Type == OrderFeeTypeCodes.Receivable + && !OrderFeeDiscountCostItems.IsDiscount(x.CostItem)) + .ToList(); + var anchor = receivable + .Where(x => MarketingFeeHelper.IsBaseFreightCostItem(x.CostItem)) + .OrderByDescending(x => ChargingStageRank(x.ChargingStage)) + .FirstOrDefault() + ?? receivable.OrderByDescending(x => ChargingStageRank(x.ChargingStage)).FirstOrDefault(); + var currency = string.IsNullOrWhiteSpace(anchor?.Currency) ? "CNY" : anchor!.Currency; + var stage = anchor?.ChargingStage; + + if (breakdown.CouponDiscountAmount > 0) + { + discountRows.Add(new OrderFee + { + Type = OrderFeeTypeCodes.Receivable, + CostItem = OrderFeeDiscountCostItems.CouponDiscount, + Amount = -breakdown.CouponDiscountAmount, + Currency = currency, + ChargingStage = stage, + PriceNo = couponCode, + PriceEffectiveTime = DateTime.Now + }); + } + + if (breakdown.LevelDiscountAmount > 0) + { + discountRows.Add(new OrderFee + { + Type = OrderFeeTypeCodes.Receivable, + CostItem = OrderFeeDiscountCostItems.MemberDiscount, + Amount = -breakdown.LevelDiscountAmount, + Currency = currency, + ChargingStage = stage, + PriceNo = breakdown.MemberLevelName, + PriceEffectiveTime = DateTime.Now + }); + } + + await OrderRepository.UpsertFeesAsync(order.Id, discountRows); + } + + /// + /// 抵扣行取入库优先、其次预报的阶段;出库阶段客户侧不展示,排最后。 + /// + private static int ChargingStageRank(string? stage) => stage?.Trim().ToLowerInvariant() switch + { + OrderFeeChargingStageCodes.Outbound => -1, + OrderFeeChargingStageCodes.Inbound => 2, + OrderFeeChargingStageCodes.Forecast => 1, + _ => 0 + }; + private static int? ResolveRecommendedPayCouponId(Order order, List coupons) { if (order.CouponInstanceId is > 0 diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 5751fe7..61eaddd 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -184,12 +184,16 @@ public partial class OrderService : ApplicationService LazyServiceProvider.GetRequiredService() .ApplySnapshotToOrder(order, breakdown, null); + string? couponCode = null; if (breakdown.CouponInstanceId.HasValue) { var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id); - await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id); + var coupon = await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id); + couponCode = coupon.CouponCode; } + await UpsertMarketingDiscountFeesAsync(order, fees, breakdown, couponCode); + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer); var pointsManager = LazyServiceProvider.GetRequiredService(); var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight( diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 1f836a7..a1dea3b 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -1075,6 +1075,8 @@ internal static class ParcelBillingCalculator "handling" => "操作费", "proxyPacking" => "代打包费", "insurance" => "保险费", + OrderFeeDiscountCostItems.CouponDiscount => "优惠券抵扣", + OrderFeeDiscountCostItems.MemberDiscount => "会员折扣", _ => costItem }; } diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs index d944692..328b537 100644 --- a/src/Oms.Application/Shared/MarketingCouponManager.cs +++ b/src/Oms.Application/Shared/MarketingCouponManager.cs @@ -266,7 +266,10 @@ public class MarketingCouponManager : IScopedDependency await _instanceRepository.UpdateAsync(instance, cancellationToken); } - public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default) + /// + /// 核销券并返回券实例(调用方可取券码写入费用行)。 + /// + public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default) { var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken); instance.Status = CouponInstanceStatus.Used; @@ -274,6 +277,7 @@ public class MarketingCouponManager : IScopedDependency instance.UseTime = DateTime.Now; instance.LockExpireTime = null; await _instanceRepository.UpdateAsync(instance, cancellationToken); + return instance; } public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default) diff --git a/src/Oms.Application/Shared/MarketingFeeHelper.cs b/src/Oms.Application/Shared/MarketingFeeHelper.cs index 52a8bf1..5ff47ca 100644 --- a/src/Oms.Application/Shared/MarketingFeeHelper.cs +++ b/src/Oms.Application/Shared/MarketingFeeHelper.cs @@ -12,8 +12,13 @@ public static class MarketingFeeHelper public static bool IsBaseFreightCostItem(string? costItem) => !string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem); + /// + /// 折前应收小计:排除支付时写入的营销抵扣行,避免重算营销金额时重复扣减。 + /// public static decimal SumReceivable(IEnumerable fees) => - fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount); + fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable + && !OrderFeeDiscountCostItems.IsDiscount(x.CostItem)) + .Sum(x => x.Amount); public static decimal SumReceivableBaseFreight(IEnumerable fees) => fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem)) diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs index 0cb6040..8039945 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs @@ -6,6 +6,23 @@ public static class OrderFeeTypeCodes public const string Payable = "payable"; } +/// +/// 营销抵扣费用项(支付时写入的负数应收行,fee_item 字典同 code)。 +/// +public static class OrderFeeDiscountCostItems +{ + /// 优惠券抵扣。 + public const string CouponDiscount = "coupon_discount"; + + /// 会员折扣。 + public const string MemberDiscount = "member_discount"; + + public static bool IsDiscount(string? costItem) => + !string.IsNullOrWhiteSpace(costItem) + && (string.Equals(costItem.Trim(), CouponDiscount, StringComparison.OrdinalIgnoreCase) + || string.Equals(costItem.Trim(), MemberDiscount, StringComparison.OrdinalIgnoreCase)); +} + public static class OrderFeeChargingStageCodes { public const string Forecast = "forecast"; -- 2.45.2 From 314c5ac02f733d1ddcd9cacf5ffedcdd4b6eb92c Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 19 Oct 2026 15:47:39 +0800 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=E7=94=B3=E6=8A=A5=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E6=96=B0=E5=A2=9E=E4=BA=A7=E5=93=81=E6=A0=87=E8=AF=86?= =?UTF-8?q?=E7=BC=96=E7=A0=81/=E9=9D=9E=E6=A0=87=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E7=BC=96=E7=A0=81/=E6=A0=87=E5=87=86?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E6=A0=87=E8=AF=86=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t_order_declaration 新增 ProductId、NonStdProductId、StdProductId(迁移 migrate_order_declaration_product_ids_20261019.sql) - 下单/改单/开放接口/Excel 模板入参、订单详情出参、下游 createOrderOne 报文同步字段 - 自动触发公式目录增加三个字段 --- db.sql | 3 + ...order_declaration_product_ids_20261019.sql | 55 +++++++++++++++++++ .../Dtos/Orders/OrderItemOutput.cs | 15 +++++ .../Dtos/Orders/OrderParcelItemInput.cs | 20 +++++++ .../Orders/Issues/AutoTriggerFormulaMeta.cs | 3 + .../Services/Orders/OrderService.Helpers.cs | 3 + .../Services/Orders/OrderService.OrderEdit.cs | 3 + .../Orders/OrderService.ParcelBilling.cs | 3 + .../Templates/OrderTemplateFieldRegistry.cs | 3 + .../Templates/OrderTemplateRowMapper.cs | 1 + .../OrderDownstreamCreateOneBodyBuilder.cs | 3 + .../Entities/OrderDeclaration.cs | 15 +++++ 12 files changed, 127 insertions(+) create mode 100644 sql/migrate_order_declaration_product_ids_20261019.sql diff --git a/db.sql b/db.sql index 2661036..198f297 100644 --- a/db.sql +++ b/db.sql @@ -432,6 +432,9 @@ CREATE TABLE `t_order_declaration` ( `ImHsCode` VARCHAR(50) NULL COMMENT '进口HsCode', `ExHsCode` VARCHAR(50) NULL COMMENT '出口HsCode', `DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素', + `ProductId` VARCHAR(100) NULL COMMENT '产品标识编码', + `NonStdProductId` VARCHAR(100) NULL COMMENT '非标产品标识编码', + `StdProductId` VARCHAR(100) NULL COMMENT '标准产品标识编码', `SkuImage` VARCHAR(512) NULL COMMENT '图片(存 storage key 或短 URL)', `Category` VARCHAR(20) NULL COMMENT '类别', `Brand` VARCHAR(50) NULL COMMENT '品牌', diff --git a/sql/migrate_order_declaration_product_ids_20261019.sql b/sql/migrate_order_declaration_product_ids_20261019.sql new file mode 100644 index 0000000..474a9fd --- /dev/null +++ b/sql/migrate_order_declaration_product_ids_20261019.sql @@ -0,0 +1,55 @@ +-- 申报明细:新增产品标识编码 / 非标产品标识编码 / 标准产品标识编码。 +-- 可重复执行。 + +SET @db := DATABASE(); + +-- t_order_declaration.ProductId +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_declaration' + AND COLUMN_NAME = 'ProductId' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order_declaration` ADD COLUMN `ProductId` VARCHAR(100) NULL COMMENT ''产品标识编码'' AFTER `DeclareElement`', + 'SELECT ''t_order_declaration.ProductId already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- t_order_declaration.NonStdProductId +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_declaration' + AND COLUMN_NAME = 'NonStdProductId' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order_declaration` ADD COLUMN `NonStdProductId` VARCHAR(100) NULL COMMENT ''非标产品标识编码'' AFTER `ProductId`', + 'SELECT ''t_order_declaration.NonStdProductId already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- t_order_declaration.StdProductId +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_declaration' + AND COLUMN_NAME = 'StdProductId' +); +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `t_order_declaration` ADD COLUMN `StdProductId` VARCHAR(100) NULL COMMENT ''标准产品标识编码'' AFTER `NonStdProductId`', + 'SELECT ''t_order_declaration.StdProductId already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs b/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs index 6797c7f..00ac7ba 100644 --- a/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderItemOutput.cs @@ -70,6 +70,21 @@ public class OrderItemOutput /// public string? DeclareElement { get; set; } + /// + /// 产品标识编码。 + /// + public string? ProductId { get; set; } + + /// + /// 非标产品标识编码。 + /// + public string? NonStdProductId { get; set; } + + /// + /// 标准产品标识编码。 + /// + public string? StdProductId { get; set; } + /// /// SKU 图片。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs index 7f29d6f..ed71218 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Oms.Application.Dtos.Orders; /// @@ -65,6 +67,24 @@ public class OrderParcelItemInput /// public string? DeclareElement { get; set; } + /// + /// 产品标识编码(选填)。 + /// + [StringLength(100)] + public string? ProductId { get; set; } + + /// + /// 非标产品标识编码(选填)。 + /// + [StringLength(100)] + public string? NonStdProductId { get; set; } + + /// + /// 标准产品标识编码(选填)。 + /// + [StringLength(100)] + public string? StdProductId { get; set; } + /// /// SKU 图片。 /// diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs index e403be4..4754b59 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerFormulaMeta.cs @@ -199,6 +199,9 @@ public static class AutoTriggerFormulaMeta yield return ("进口HS编码", "t_order_declaration", "ImHsCode", FormulaValueType.String, false); yield return ("出口HS编码", "t_order_declaration", "ExHsCode", FormulaValueType.String, false); yield return ("出口申报要素", "t_order_declaration", "DeclareElement", FormulaValueType.String, false); + yield return ("产品标识编码", "t_order_declaration", "ProductId", FormulaValueType.String, false); + yield return ("非标产品标识编码", "t_order_declaration", "NonStdProductId", FormulaValueType.String, false); + yield return ("标准产品标识编码", "t_order_declaration", "StdProductId", FormulaValueType.String, false); yield return ("申报类别", "t_order_declaration", "Category", FormulaValueType.String, false); yield return ("品牌", "t_order_declaration", "Brand", FormulaValueType.String, false); yield return ("品牌类型", "t_order_declaration", "BrandType", FormulaValueType.String, false); diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index c15f066..d61441a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -817,6 +817,9 @@ public partial class OrderService ImHsCode = x.ImHsCode ?? x.HsCode, ExHsCode = x.ExHsCode ?? x.HsCode, DeclareElement = x.DeclareElement, + ProductId = x.ProductId, + NonStdProductId = x.NonStdProductId, + StdProductId = x.StdProductId, SkuImage = FileStorage.GetPublicUrl(x.SkuImage), Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 1f95706..2fad81d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -1360,6 +1360,9 @@ public partial class OrderService ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode ?? x.ImHsCode, DeclareElement = x.DeclareElement, + ProductId = x.ProductId?.Trim(), + NonStdProductId = x.NonStdProductId?.Trim(), + StdProductId = x.StdProductId?.Trim(), SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage), Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index e9afb21..abaa68f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2213,6 +2213,9 @@ public partial class OrderService ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode, DeclareElement = x.DeclareElement, + ProductId = x.ProductId?.Trim(), + NonStdProductId = x.NonStdProductId?.Trim(), + StdProductId = x.StdProductId?.Trim(), SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage), Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs index b5be7bd..ede5f25 100644 --- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateFieldRegistry.cs @@ -215,6 +215,9 @@ public static class OrderTemplateFieldRegistry Add("items[0].weight", "总重量(kg)", false, "number", group: "declaration", example: "自动计算", hint: "不填=单重×数量"); Add("items[0].imHsCode", "进口HsCode", false, group: "declaration", hint: "选填"); Add("items[0].exHsCode", "出口HsCode", false, group: "declaration", hint: "选填"); + Add("items[0].productId", "产品标识编码", false, group: "declaration", hint: "选填"); + Add("items[0].nonStdProductId", "非标产品标识编码", false, group: "declaration", hint: "选填"); + Add("items[0].stdProductId", "标准产品标识编码", false, group: "declaration", hint: "选填"); Add("items[0].skuImage", "商品图片URL", false, group: "declaration", hint: "选填"); Add("items[0].category", "类别", false, group: "declaration", example: "Accessories", hint: "选填"); Add("items[0].brand", "品牌", false, group: "declaration", example: "DemoBrand", hint: "选填"); diff --git a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs index b1470a6..c308c56 100644 --- a/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs +++ b/src/Oms.Application/Services/Orders/Templates/OrderTemplateRowMapper.cs @@ -20,6 +20,7 @@ public static class OrderTemplateRowMapper "sender.countryCode", "sender.province", "sender.city", "sender.zipCode", "sender.streetAddress1", "sender.streetAddress2", "sender.streetAddress3", "sender.houseNumber", "items[0].cnName", "items[0].enName", "items[0].sku", "items[0].unit", "items[0].imHsCode", "items[0].exHsCode", "items[0].skuImage", + "items[0].productId", "items[0].nonStdProductId", "items[0].stdProductId", "items[0].category", "items[0].brand", "items[0].brandType", "items[0].distribution", "items[0].material", "items[0].enMaterial", "items[0].purpose", "items[0].attributes", "items[0].originCountry", "items[0].salesLink", "items[0].model", "items[0].specifications", "items[0].comment" diff --git a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs index 9908371..501a2a9 100644 --- a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs +++ b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs @@ -201,6 +201,9 @@ public static class OrderDownstreamCreateOneBodyBuilder ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode ?? x.HsCode, DeclareElement = x.DeclareElement, + ProductId = x.ProductId, + NonStdProductId = x.NonStdProductId, + StdProductId = x.StdProductId, SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage, Category = x.Category, Brand = x.Brand, diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs index ab158c3..39544ca 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderDeclaration.cs @@ -37,6 +37,21 @@ public class OrderDeclaration : OmsAuditedEntity /// public string? DeclareElement { get; set; } + /// + /// 产品标识编码。 + /// + public string? ProductId { get; set; } + + /// + /// 非标产品标识编码。 + /// + public string? NonStdProductId { get; set; } + + /// + /// 标准产品标识编码。 + /// + public string? StdProductId { get; set; } + public string? SkuImage { get; set; } public string? Category { get; set; } -- 2.45.2 From e931606a756b2ade06fe57e8ffe13a9c1532b0ff Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 19 Oct 2026 16:24:02 +0800 Subject: [PATCH 15/15] =?UTF-8?q?feat:=209610=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BB=8E=E5=B7=B2=E6=9C=89=E8=BF=90=E5=8D=95=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E6=B1=87=E6=80=BB=E7=94=B3=E6=8A=A5=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /order/customs-9610/create-from-orders:运单设为 9610Summary 并绑定出口资质备案(空则客户默认),按申报明细生成待申报单 - 逐单返回结果:已删除、已有 9610 单、无申报明细、无可用备案记为失败,不影响其它运单 --- .../Dtos/Orders/Customs9610Dtos.cs | 65 ++++++++++ .../Orders/Customs9610OrderService.cs | 113 ++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs b/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs index 5d7f66b..69d0acc 100644 --- a/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs +++ b/src/Oms.Application/Dtos/Orders/Customs9610Dtos.cs @@ -319,3 +319,68 @@ public class Customs9610DeclareBatchOutput public List Items { get; set; } = []; } + +/// +/// 从已有运单批量创建 9610 汇总申报单入参。 +/// +public class Customs9610CreateFromOrdersInput +{ + /// + /// 运单 Id 列表(t_order.Id)。 + /// + [Required] + [MinLength(1)] + public List OrderIds { get; set; } = []; + + /// + /// 出口资质备案 Id;为空时按各运单客户的默认备案。 + /// + public int? ExportQualificationId { get; set; } + + /// + /// 电商平台编码(e-commerce_platform 字典),最长 16。 + /// + [Required] + [StringLength(16)] + public string PlatformCode { get; set; } = string.Empty; +} + +/// +/// 单个运单创建 9610 申报单结果。 +/// +public class Customs9610CreateFromOrderResult +{ + /// + /// 运单 Id。 + /// + public int OrderId { get; set; } + + /// + /// 运单号。 + /// + public string? ServiceNo { get; set; } + + public bool Success { get; set; } + + /// + /// 失败原因;成功为空。 + /// + public string? Message { get; set; } + + /// + /// 生成的 9610 申报单 Id。 + /// + public int? Customs9610OrderId { get; set; } +} + +/// +/// 批量创建 9610 申报单结果。 +/// +public class Customs9610CreateFromOrdersOutput +{ + public int SuccessCount { get; set; } + + public int FailCount { get; set; } + + public List Items { get; set; } = []; +} diff --git a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs index 2e45bea..9789c4b 100644 --- a/src/Oms.Application/Services/Orders/Customs9610OrderService.cs +++ b/src/Oms.Application/Services/Orders/Customs9610OrderService.cs @@ -41,6 +41,12 @@ public class Customs9610OrderService : ApplicationService private IOptions ItfxOptions => LazyServiceProvider.GetRequiredService>(); + private ICustomerExportQualificationRepository ExportQualificationRepository => + LazyServiceProvider.GetRequiredService(); + + private Customs9610OrderWriter Customs9610Writer => + LazyServiceProvider.GetRequiredService(); + [HttpPost("/order/customs-9610/list")] public async Task> ListAsync([FromBody] Customs9610ListInput input) { @@ -209,6 +215,113 @@ public class Customs9610OrderService : ApplicationService }; } + /// + /// 从已有运单批量创建 9610 汇总申报单:运单改为 9610Summary 并绑定出口资质备案,按运单申报明细生成待申报单。 + /// + /// 运单 Id 列表、出口资质备案 Id(空则用各客户默认备案)、电商平台编码。 + /// 逐单结果;已删除、已有 9610 单、无申报明细、无可用备案的运单记为失败,不影响其它运单。 + [HttpPost("/order/customs-9610/create-from-orders")] + public async Task CreateFromOrdersAsync( + [FromBody] Customs9610CreateFromOrdersInput input) + { + var platformCode = input.PlatformCode?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(platformCode)) + { + throw new BusinessException("请选择电商平台"); + } + + if (platformCode.Length > 16) + { + throw new BusinessException("电商平台编码不能超过 16 个字符"); + } + + var orderIds = input.OrderIds.Where(x => x > 0).Distinct().ToList(); + if (orderIds.Count == 0) + { + throw new BusinessException("请选择运单"); + } + + var sessionCustomerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync( + CustomerRepository, + CurrentUser, + null); + var orders = (await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id))) + .ToDictionary(x => x.Id); + var declarations = await OrderRepository.GetDeclarationsByOrderIdsAsync(orderIds); + var ordersWithItems = declarations.Select(x => x.OrderId).ToHashSet(); + + var results = new List(); + foreach (var orderId in orderIds) + { + var result = new Customs9610CreateFromOrderResult { OrderId = orderId }; + results.Add(result); + try + { + if (!orders.TryGetValue(orderId, out var order) + || (sessionCustomerId is > 0 && order.CustomerId != sessionCustomerId.Value)) + { + throw new BusinessException("运单不存在或无权访问"); + } + + result.ServiceNo = order.ServiceNo; + if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("运单已删除"); + } + + if (order.CustomerId is not > 0) + { + throw new BusinessException("运单未关联客户"); + } + + if (await HeaderRepository.GetByOrderIdAsync(order.Id) != null) + { + throw new BusinessException("该运单已存在 9610 申报单"); + } + + if (!ordersWithItems.Contains(order.Id)) + { + throw new BusinessException("运单没有申报明细"); + } + + var qualification = input.ExportQualificationId is > 0 + ? await ExportQualificationRepository.GetOwnedAsync( + input.ExportQualificationId.Value, + order.CustomerId.Value) + : await ExportQualificationRepository.GetDefaultAsync(order.CustomerId.Value); + if (qualification == null) + { + throw new BusinessException(input.ExportQualificationId is > 0 + ? "所选出口资质备案不属于该运单客户" + : "运单客户未维护默认出口资质备案"); + } + + order.ExportCustomsMode = ExportCustomsModeCodes.Mode9610Summary; + order.ExportQualificationId = qualification.Id; + await Customs9610Writer.UpsertFromOrderAsync(order, platformCode); + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + var header = await HeaderRepository.GetByOrderIdAsync(order.Id); + result.Customs9610OrderId = header?.Id; + result.Success = header != null; + result.Message = header == null ? "9610 申报单生成失败" : null; + } + catch (Exception ex) + { + result.Success = false; + result.Message = string.IsNullOrWhiteSpace(ex.Message) ? "创建失败" : ex.Message; + } + } + + return new Customs9610CreateFromOrdersOutput + { + SuccessCount = results.Count(x => x.Success), + FailCount = results.Count(x => !x.Success), + Items = results, + }; + } + /// /// 批量获取清单数据(调 API 中心上游,标准码暂定),成功后回写本地字段。 /// -- 2.45.2