From 79fa20a9ca3abd1bbe1e8ac3fcb2409643f1e180 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 14 Oct 2026 19:23:07 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=E5=88=9B=E5=8D=95=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E5=90=8E=E6=8A=8A=E9=A2=84=E6=8A=A5=E8=AE=A1=E8=B4=B9=E5=92=8C?= =?UTF-8?q?=E5=90=8E=E7=BB=AD=E4=BA=8B=E4=BB=B6=E6=8B=86=E5=BC=80=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预报计费失败不再把订单留在处理中,轨迹、面单和自动触发在创单提交后各自入队。 Co-authored-by: Cursor --- .../Labeling/LabelPdfGenerationService.cs | 3 +- .../Messaging/OmsAfterCommit.cs | 49 +++ .../Messaging/OmsOutboxUnitOfWork.cs | 10 + .../Messaging/OrderBatchJobEventHandler.cs | 2 +- .../Orders/Batch/OrderBatchJobProcessor.cs | 40 +++ .../Orders/OrderService.ParcelBilling.cs | 302 +++++++++++++----- .../Repositories/IOrderRepository.cs | 5 + .../Orders/OrderRepository.cs | 9 + 8 files changed, 329 insertions(+), 91 deletions(-) create mode 100644 src/Oms.Application/Messaging/OmsAfterCommit.cs diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs index a6da67b..7e3287b 100644 --- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -53,8 +53,7 @@ public sealed class LabelPdfGenerationService( var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken); order.LabelTemplateId = templateId; - order.UpdateTime = DateTime.Now; - await orderRepository.UpdateAsync(order); + await orderRepository.UpdateLabelTemplateIdAsync(order.Id, templateId.Value, cancellationToken); await orderFileManager.ReplaceSystemLabelAsync( order.Id, diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs new file mode 100644 index 0000000..66f07fb --- /dev/null +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Fake.UnitOfWork; + +namespace Oms.Application.Messaging; + +/// +/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 +/// +internal static class OmsAfterCommit +{ + public static async Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) + { + var current = unitOfWorkManager.Current; + if (current != null && !current.IsCompleted) + { + current.OnCompleted(action); + return; + } + + await 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/OmsOutboxUnitOfWork.cs b/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs index dd1c83b..8d868d5 100644 --- a/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs +++ b/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs @@ -17,6 +17,16 @@ public static class OmsOutboxUnitOfWork IsolationLevel = IsolationLevel.ReadCommitted }; + /// + /// 与当前业务事务脱开的短事务,用于创单/预报成功之后的独立事件。 + /// + public static UnitOfWorkAttribute Independent() => new() + { + IsTransactional = true, + RequiresNew = true, + IsolationLevel = IsolationLevel.ReadCommitted + }; + public static bool IsAmbientTransactional(IUnitOfWorkManager unitOfWorkManager) => unitOfWorkManager.Current?.Context.IsTransactional == true; } diff --git a/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs b/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs index 95b3ad4..32451db 100644 --- a/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs +++ b/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs @@ -29,7 +29,7 @@ public sealed class OrderBatchJobEventHandler( return; } - // ForecastBilling 成功后继续写轨迹/面单/自动触发 Outbox,须事务性 UoW + // 业务结果在本事务提交;轨迹/面单/自动触发由处理过程登记到提交后单独执行。 using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); await processor.ProcessAsync(@event.ToMessage(), cancellationToken); await unitOfWork.CompleteAsync(cancellationToken); diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs index 9b04635..071ad64 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Oms.Application.Dtos.Orders; using Oms.Application.Messaging; +using Oms.Application.Services.Orders; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; @@ -94,6 +95,7 @@ public sealed class OrderBatchJobProcessor( await OrderBatchJobItemFeedback.EnrichIdentifiersAsync(item, action, orderRepository, cancellationToken); OrderBatchJobItemFeedback.ApplyBusinessFailure(item, result, action); await batchJobRepository.UpdateItemAsync(item, cancellationToken); + await TryStampForecastBillingFailureAsync(message, item.FailReason, cancellationToken); await RefreshJobSummaryAsync(item.JobId, cancellationToken); return; @@ -111,6 +113,7 @@ public sealed class OrderBatchJobProcessor( action, item.RetryCount, item.FailReason); + await TryStampForecastBillingFailureAsync(message, item.FailReason, cancellationToken); await RefreshJobSummaryAsync(item.JobId, cancellationToken); return; } @@ -148,6 +151,43 @@ public sealed class OrderBatchJobProcessor( return null; } + /// + /// 预报计费重试耗尽或业务失败时,把原因写回订单,避免一直显示「处理中」。 + /// 更具体的失败原因已经写上时不再覆盖。 + /// + private async Task TryStampForecastBillingFailureAsync( + OrderBatchJobMessage message, + string? reason, + CancellationToken cancellationToken) + { + if (!string.Equals(message.Action, OrderBatchActionCodes.ForecastBilling, StringComparison.OrdinalIgnoreCase) + || message.OrderId <= 0) + { + return; + } + + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == message.OrderId, cancellationToken); + if (order == null + || !string.Equals( + order.PredeclareFailReason, + OrderService.ForecastBillingProcessingReason, + StringComparison.Ordinal)) + { + return; + } + + var text = string.IsNullOrWhiteSpace(reason) ? "预报计费失败" : reason.Trim(); + if (text.Length > 512) + { + text = text[..512]; + } + + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = text; + order.UpdateTime = DateTime.Now; + await orderRepository.UpdateAsync(order, cancellationToken); + } + private async Task RefreshJobSummaryAsync(int jobId, CancellationToken cancellationToken) { var job = await batchJobRepository.GetByIdAsync(jobId, cancellationToken); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 1085937..afe1617 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -3,6 +3,7 @@ using Fake.Application; using Fake.UnitOfWork; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Oms.Application.Dtos.Channels; @@ -39,7 +40,7 @@ public partial class OrderService private const int ParcelBatchMaxCount = 2000; private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; - private const string ForecastBillingProcessingReason = "预报计费/排货处理中"; + internal const string ForecastBillingProcessingReason = "预报计费/排货处理中"; private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报"; private static readonly ConcurrentDictionary InboundBillingInFlight = new(); @@ -99,19 +100,6 @@ public partial class OrderService }; } - /// - /// 业务写库前先打开 Outbox DbContext,让 EF 先占环境事务连接, - /// 避免 SqlSugar 先开事务后 Outbox UseTransaction 失败而落入「短事务提前 Commit」。 - /// - private async Task EnsureOutboxReadyAsync() - { - var uowManager = LazyServiceProvider.GetRequiredService(); - if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager)) - { - await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(uowManager); - } - } - private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, int? operatorUserId = null, @@ -164,12 +152,10 @@ public partial class OrderService country.ChineseName); var serviceNo = await ResolveServiceNoAsync(); + var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); Order? created = null; try { - // 必须在任何 SqlSugar 写之前:保证 9610 / ForecastBilling / Pickup Outbox 与建单同事务提交。 - await EnsureOutboxReadyAsync(); - var order = BuildDraftOrderEntity( input, product, @@ -184,12 +170,11 @@ public partial class OrderService var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); - // 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。 + // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 if (input.SaveAsDraft) { - await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); var draftOutput = await BuildParcelCreateOutputAsync(order); - await TryAttachPickupAfterParcelCreateAsync(input, draftOutput); + await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); return draftOutput; } @@ -207,18 +192,8 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); - await EnqueueForecastBillingAsync( - order, - input.CouponInstanceId, - operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - var output = await BuildParcelCreateOutputAsync(order); - output.ForecastBillingQueued = string.Equals( - order.PredeclareFailReason, - ForecastBillingProcessingReason, - StringComparison.Ordinal); - await TryAttachPickupAfterParcelCreateAsync(input, output); + await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); return output; } catch (BusinessException) @@ -2326,8 +2301,6 @@ public partial class OrderService try { - await EnsureOutboxReadyAsync(); - var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); var address = await ResolveOrderBillingAddressAsync(order); @@ -2355,10 +2328,24 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await EnqueueForecastBillingAsync( - order, - order.CouponInstanceId, - operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); + var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload + { + CouponInstanceId = order.CouponInstanceId + }); + await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); + } + else + { + var orderId = order.Id; + var couponInstanceId = order.CouponInstanceId; + await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); + } + + await ScheduleForecastSideEffectsAsync(order.Id, operatorId); // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); @@ -2681,10 +2668,10 @@ public partial class OrderService var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null) { - return; + // 创单事务尚未提交时消息可能先到。抛出让总线重试,避免当成成功 Ack 后订单永远停在处理中。 + throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); } - // 创单失败回滚 / 软删后,可能仍有短事务提前投递的孤儿任务 if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; @@ -2852,21 +2839,6 @@ public partial class OrderService operatorName: null, operatorType: "staff", source: "job"); - - var customer = order.CustomerId.HasValue - ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) - : null; - await PublishParcelCreateTrackAndLabelAsync( - order, - customer, - receiver: null, - appendInitialTrack: true, - operatorUserId); - - await AutoTriggerRuntimeService.EnqueueFireAsync( - order.Id, - AutoTriggerTiming.OrderCreate, - operatorUserId); } private sealed class ForecastBillingJobPayload @@ -2874,6 +2846,188 @@ public partial class OrderService public int? CouponInstanceId { 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; + + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => + { + try + { + var customsTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => + sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); + var forecastTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => + { + var service = sp.GetRequiredService(); + await service.EnqueueForecastBillingByIdAsync(orderId, couponInstanceId, operatorUserId); + var latest = await sp.GetRequiredService() + .FirstOrDefaultAsync(x => x.Id == orderId); + if (latest != null) + { + output.OrderStatus = latest.OrderStatus; + output.PredeclareFailReason = latest.PredeclareFailReason; + output.ForecastBillingQueued = string.Equals( + latest.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal); + } + }) + : Task.FromResult(true); + var pickupTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => + sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output)); + var trackTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId)) + : Task.FromResult(true); + var labelTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)) + : Task.FromResult(true); + var triggerTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)) + : Task.FromResult(true); + + await Task.WhenAll(customsTask, forecastTask, pickupTask, trackTask, labelTask, triggerTask); + + if (includeForecast && !await forecastTask) + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => + sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); + output.ForecastBillingQueued = false; + output.PredeclareFailReason = ForecastBillingEnqueueFailReason; + } + } + catch (Exception ex) + { + logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId); + } + }); + } + + private async Task ScheduleForecastEnqueueAsync(int orderId, int? couponInstanceId, int operatorUserId) + { + 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))); + } + + /// + /// 创单或草稿重新预报提交后,轨迹、面单、下单自动触发各自入队,不等预报计费。 + /// + 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, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId)); + 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)); + }); + } + + 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) + { + 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); + } + + 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) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var customer = order.CustomerId.HasValue + ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) + : null; + var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); + request.AppendInitialTrackOnSuccess = true; + ApplyTrackAuthorization(request); + 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)) + { + 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); + } + private async Task EnqueueForecastBillingAsync( Order order, int? couponInstanceId, @@ -3206,40 +3360,8 @@ public partial class OrderService private IOrderSideEffectEventPublisher SideEffectEventPublisher => LazyServiceProvider.GetRequiredService(); - /// - /// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。 - /// - private async Task PublishParcelCreateTrackAndLabelAsync( - Order order, - Customer? customer, - OrderParcelReceiverInput? receiver, - bool appendInitialTrack, - int? operatorUserId = null) - { - var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); - request.AppendInitialTrackOnSuccess = appendInitialTrack; - var authorization = LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); - if (!string.IsNullOrWhiteSpace(authorization)) - { - request.Authorization = authorization; - } - - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); - await ParcelCreateEventPublisher.PublishLabelGenerateAsync( - order, - operatorUserId - ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - } - /// /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 - /// 入库等非创单路径仍可调用;创单路径请走 。 /// private async Task EnqueueSmallBagTrackUpsertAsync( Order order, @@ -3249,6 +3371,12 @@ public partial class OrderService { var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); request.AppendInitialTrackOnSuccess = appendInitialTrack; + ApplyTrackAuthorization(request); + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + } + + private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request) + { var authorization = LazyServiceProvider .GetRequiredService() .HttpContext? @@ -3260,8 +3388,6 @@ public partial class OrderService { request.Authorization = authorization; } - - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } /// diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index 1cd7722..c5d8ab0 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -132,4 +132,9 @@ public interface IOrderRepository : IRepository DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default); + + /// + /// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。 + /// + Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index b7d0884..c85d43f 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1021,6 +1021,15 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I } } + public async Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => new Order { LabelTemplateId = labelTemplateId, UpdateTime = DateTime.Now }) + .Where(x => x.Id == orderId) + .ExecuteCommandAsync(cancellationToken); + } + public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); From 61b6f9a9b9504222e0a8d94b9a1d85a6281d7c32 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 14 Oct 2026 19:37:56 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=E5=88=9B=E5=8D=95=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E5=90=8E=E5=86=8D=E6=8F=90=E4=BA=A4=209610?= =?UTF-8?q?=E3=80=81=E8=BD=A8=E8=BF=B9=E5=92=8C=E6=8F=BD=E6=94=B6=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免在创单事务里写 Outbox 征用不到连接,导致这三类事件被丢掉。 Co-authored-by: Cursor --- .../Messaging/OmsAfterCommit.cs | 16 ++++-- .../Messaging/OmsIntegrationEventPublisher.cs | 16 ++++-- .../Orders/OrderService.ParcelBilling.cs | 51 +++++++------------ 3 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs index 66f07fb..9b31ee7 100644 --- a/src/Oms.Application/Messaging/OmsAfterCommit.cs +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -9,16 +9,24 @@ namespace Oms.Application.Messaging; /// internal static class OmsAfterCommit { - public static async Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) + public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) { var current = unitOfWorkManager.Current; if (current != null && !current.IsCompleted) { - current.OnCompleted(action); - return; + // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 + // 等工作单元释放后再开独立事务。 + EventHandler handler = null!; + handler = (_, _) => + { + current.Disposed -= handler; + _ = Task.Run(action); + }; + current.Disposed += handler; + return Task.CompletedTask; } - await action(); + return action(); } /// diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs index 956bc1e..b572be4 100644 --- a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs +++ b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs @@ -33,9 +33,9 @@ internal static class OmsIntegrationEventPublisher @event.Id); return; } - catch (Exception ex) when (IsTxConnectionMismatch(ex)) + catch (Exception ex) when (ShouldUseIndependentOutbox(ex)) { - // SqlSugar 已在另一条连接上开事务时,EF 无法征用该 DbTransaction。 + // 环境事务已提交、连接对不上,或还没有可征用的数据库事务时,改走独立短事务。 logger.LogWarning( ex, "Ambient UoW TX incompatible with EF Outbox; using short outbox TX. Event={EventName} Id={EventId}", @@ -97,12 +97,18 @@ internal static class OmsIntegrationEventPublisher return dbProvider.GetDbContextAsync(); } - private static bool IsTxConnectionMismatch(Exception ex) + private static bool ShouldUseIndependentOutbox(Exception ex) { for (var e = ex; e != null; e = e.InnerException!) { - if (e.Message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase) - || e.Message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase)) + 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; } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index afe1617..2d66718 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2866,49 +2866,32 @@ public partial class OrderService { try { - var customsTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); - var forecastTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => + + 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); - var latest = await sp.GetRequiredService() - .FirstOrDefaultAsync(x => x.Id == orderId); - if (latest != null) - { - output.OrderStatus = latest.OrderStatus; - output.PredeclareFailReason = latest.PredeclareFailReason; - output.ForecastBillingQueued = string.Equals( - latest.PredeclareFailReason, - ForecastBillingProcessingReason, - StringComparison.Ordinal); - } - }) - : Task.FromResult(true); - var pickupTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => + }); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId)); + 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)); - var trackTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId)) - : Task.FromResult(true); - var labelTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => - sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)) - : Task.FromResult(true); - var triggerTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => - sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)) - : Task.FromResult(true); - await Task.WhenAll(customsTask, forecastTask, pickupTask, trackTask, labelTask, triggerTask); - - if (includeForecast && !await forecastTask) + if (includeForecast && !forecastQueued) { await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); - output.ForecastBillingQueued = false; - output.PredeclareFailReason = ForecastBillingEnqueueFailReason; } } catch (Exception ex) From 34c47a3173be00b21bd908880c682928f128696f Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 14 Oct 2026 20:07:51 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=E5=88=9B=E5=8D=95=E5=90=8E=E7=BD=AE?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E9=9A=8F=E8=AE=A2=E5=8D=95=E8=90=BD=E5=BA=93?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E4=BA=A4=E5=90=8E=E5=86=8D=E5=8D=95=E7=8B=AC?= =?UTF-8?q?=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进程重启或事务回滚不再丢掉 9610、轨迹和揽收;排货完成后再补轨迹、面单和计费相关触发。 Co-authored-by: Cursor --- db.sql | 21 ++ sql/migrate_order_side_effect.sql | 39 +++ .../Messaging/OmsAfterCommit.cs | 59 ++-- .../Messaging/OrderSideEffectExecutor.cs | 202 +++++++++++ .../Messaging/OrderSideEffectPayload.cs | 34 ++ .../Messaging/OrderSideEffectQueue.cs | 83 +++++ src/Oms.Application/OmsApplicationModule.cs | 1 + .../Orders/Batch/OrderBatchJobProcessor.cs | 5 +- .../Issues/AutoTriggerRuntimeService.cs | 94 +++++- .../Issues/OrderExceptionWorkflowService.cs | 52 +-- .../Orders/OrderService.ParcelBilling.cs | 176 ++-------- .../Orders/OrderService.SideEffects.cs | 317 ++++++++++++++++++ .../Services/Orders/OrderService.cs | 4 +- .../Workers/OrderSideEffectWorker.cs | 41 +++ .../Consts/OrderSideEffectCodes.cs | 44 +++ .../Entities/OrderSideEffect.cs | 30 ++ .../Repositories/IOrderRepository.cs | 19 ++ .../IOrderSideEffectRepository.cs | 55 +++ .../Orders/OrderRepository.cs | 81 +++++ .../Orders/OrderSideEffectRepository.cs | 153 +++++++++ 20 files changed, 1307 insertions(+), 203 deletions(-) create mode 100644 sql/migrate_order_side_effect.sql create mode 100644 src/Oms.Application/Messaging/OrderSideEffectExecutor.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffectPayload.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffectQueue.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.SideEffects.cs create mode 100644 src/Oms.Application/Workers/OrderSideEffectWorker.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs create mode 100644 src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs diff --git a/db.sql b/db.sql index 3f1111e..199ec6e 100644 --- a/db.sql +++ b/db.sql @@ -101,6 +101,7 @@ DROP TABLE IF EXISTS `t_order_weight`; DROP TABLE IF EXISTS `t_order_declaration`; DROP TABLE IF EXISTS `t_order_recipient`; DROP TABLE IF EXISTS `t_order_sender`; +DROP TABLE IF EXISTS `t_order_side_effect`; DROP TABLE IF EXISTS `t_order_batch_job_item`; DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_template`; @@ -306,6 +307,26 @@ CREATE TABLE `t_order_template` ( KEY `idx_ord_tpl_customer_type` (`CustomerId`, `Type`) ) COMMENT='下单模板表'; +CREATE TABLE `t_order_side_effect` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OrderId` INT NOT NULL COMMENT '订单 Id', + `Step` VARCHAR(64) NOT NULL COMMENT '步骤:customs-9610/forecast-billing/track-upsert 等', + `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/running/done/failed', + `PayloadJson` TEXT NULL COMMENT '步骤入参', + `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT '已尝试次数', + `NextRunTime` DATETIME NULL COMMENT '下次可执行时间', + `LastError` VARCHAR(512) NULL COMMENT '最近一次失败原因', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), + KEY `idx_order_side_effect_order` (`OrderId`, `Step`) +) COMMENT='创单后置步骤'; + CREATE TABLE `t_order_batch_job` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等', diff --git a/sql/migrate_order_side_effect.sql b/sql/migrate_order_side_effect.sql new file mode 100644 index 0000000..20fbf7a --- /dev/null +++ b/sql/migrate_order_side_effect.sql @@ -0,0 +1,39 @@ +-- 创单后置步骤表:与订单同一事务写入,提交后由后台执行。 +-- 可重复执行。不建唯一索引。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_side_effect' +); + +SET @sql := IF( + @exists = 0, + 'CREATE TABLE `t_order_side_effect` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `OrderId` INT NOT NULL COMMENT ''订单 Id'', + `Step` VARCHAR(64) NOT NULL COMMENT ''步骤:customs-9610/forecast-billing/track-upsert 等'', + `Status` VARCHAR(32) NOT NULL DEFAULT ''pending'' COMMENT ''pending/running/done/failed'', + `PayloadJson` TEXT NULL COMMENT ''步骤入参,含轨迹授权头,日志中不要打印全文'', + `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT ''已尝试次数'', + `NextRunTime` DATETIME NULL COMMENT ''下次可执行时间'', + `LastError` VARCHAR(512) NULL COMMENT ''最近一次失败原因'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), + KEY `idx_order_side_effect_order` (`OrderId`, `Step`) + ) COMMENT=''创单后置步骤''', + 'SELECT ''t_order_side_effect already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs index 9b31ee7..deda445 100644 --- a/src/Oms.Application/Messaging/OmsAfterCommit.cs +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -1,11 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Fake.UnitOfWork; namespace Oms.Application.Messaging; /// -/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 +/// 当前事务提交并释放后再执行。回滚不会执行。 /// internal static class OmsAfterCommit { @@ -14,13 +12,36 @@ internal static class OmsAfterCommit var current = unitOfWorkManager.Current; if (current != null && !current.IsCompleted) { - // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 - // 等工作单元释放后再开独立事务。 + // OnCompleted 仍处在业务事务里,此时写 Outbox 征用不到连接。 + // 等工作单元成功释放后再开独立事务。回滚时 IsCompleted 为 false,不跑。 EventHandler handler = null!; handler = (_, _) => { current.Disposed -= handler; - _ = Task.Run(action); + if (!current.IsCompleted) + { + return; + } + + var suppressed = ExecutionContext.SuppressFlow(); + try + { + _ = Task.Run(async () => + { + try + { + await action(); + } + catch + { + // 具体步骤在执行器里记日志。这里避免未观察的任务异常。 + } + }); + } + finally + { + suppressed.Dispose(); + } }; current.Disposed += handler; return Task.CompletedTask; @@ -28,30 +49,4 @@ internal static class OmsAfterCommit 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/OrderSideEffectExecutor.cs b/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs new file mode 100644 index 0000000..bf46bc5 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs @@ -0,0 +1,202 @@ +using Fake.Application; +using Fake.DependencyInjection; +using Fake.UnitOfWork; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Messaging; + +/// +/// 每一步后置任务单独开事务执行。失败按次数重试,超过次数记失败。 +/// +public sealed class OrderSideEffectExecutor( + IOrderSideEffectRepository repository, + IUnitOfWorkManager unitOfWorkManager, + OrderService orderService, + ILogger logger) : IScopedDependency +{ + private static readonly TimeSpan StaleRunning = TimeSpan.FromMinutes(2); + + /// + /// 当前事务提交成功后再执行到期步骤。回滚不会触发。 + /// + public static Task ScheduleAfterCommitAsync( + IUnitOfWorkManager unitOfWorkManager, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + return OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => ProcessDueAsync(scopeFactory, logger)); + } + + /// + /// 捞出到期步骤并逐条执行。可重复调用,抢占失败的步骤会被跳过。 + /// + public static async Task ProcessDueAsync( + IServiceScopeFactory scopeFactory, + ILogger logger, + CancellationToken cancellationToken = default) + { + List ids; + try + { + using var scope = scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.Now; + await repository.ResetStaleRunningAsync(now.Subtract(StaleRunning), now, cancellationToken); + ids = await repository.ListDueIdsAsync(30, now, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "捞取创单后置步骤失败"); + return; + } + + foreach (var id in ids) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + using var scope = scopeFactory.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + await executor.ExecuteAsync(id, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "订单后置步骤执行失败 SideEffectId={SideEffectId}", id); + } + } + } + + /// + /// 仍停在「预报计费/排货处理中」且没有未完成预报步骤的订单,补一条预报计费步骤。 + /// + public static async Task RecoverStuckForecastAsync( + IServiceScopeFactory scopeFactory, + ILogger logger, + CancellationToken cancellationToken = default) + { + try + { + using var scope = scopeFactory.CreateScope(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + var repository = scope.ServiceProvider.GetRequiredService(); + var queue = scope.ServiceProvider.GetRequiredService(); + var olderThan = DateTime.Now.AddSeconds(-90); + var orderIds = await repository.ListStuckForecastOrderIdsAsync( + OrderService.ForecastBillingProcessingReason, + olderThan, + 20, + cancellationToken); + foreach (var orderId in orderIds) + { + await queue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.ForecastBilling, + new OrderSideEffectPayload(), + cancellationToken); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "补投递卡住的预报计费失败"); + } + } + + private async Task ExecuteAsync(int id, CancellationToken cancellationToken) + { + var claimed = await ClaimAsync(id, cancellationToken); + if (!claimed) + { + return; + } + + var row = await repository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (row == null) + { + return; + } + + try + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); + await orderService.ExecuteSideEffectAsync(row, cancellationToken); + await repository.MarkDoneAsync(row.Id, DateTime.Now, cancellationToken); + await unitOfWork.CompleteAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "订单后置步骤失败 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); + var exhausted = await MarkRetryAsync(row, ex, cancellationToken); + if (exhausted + && string.Equals(row.Step, OrderSideEffectSteps.ForecastBilling, StringComparison.Ordinal)) + { + await StampForecastEnqueueFailureAsync(row.OrderId, cancellationToken); + } + } + } + + private async Task ClaimAsync(int id, CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + var claimed = await repository.TryClaimAsync(id, DateTime.Now, cancellationToken); + await unitOfWork.CompleteAsync(cancellationToken); + return claimed; + } + + private async Task MarkRetryAsync( + Domain.OrdersAggregate.Entities.OrderSideEffect row, + Exception ex, + CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + var latest = await repository.FirstOrDefaultAsync(x => x.Id == row.Id, cancellationToken); + var attempts = latest?.AttemptCount ?? row.AttemptCount; + var exhausted = attempts >= OrderSideEffectQueue.MaxAttempts; + var message = ex is BusinessException business ? business.Message : ex.Message; + if (string.IsNullOrWhiteSpace(message)) + { + message = ex.GetType().Name; + } + + if (message.Length > 512) + { + message = message[..512]; + } + + var now = DateTime.Now; + await repository.MarkRetryAsync( + row.Id, + exhausted ? OrderSideEffectStatusCodes.Failed : OrderSideEffectStatusCodes.Pending, + message, + exhausted ? null : now.AddSeconds(15 * Math.Max(1, attempts)), + now, + cancellationToken); + await unitOfWork.CompleteAsync(cancellationToken); + return exhausted; + } + + private async Task StampForecastEnqueueFailureAsync(int orderId, CancellationToken cancellationToken) + { + try + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + await orderService.MarkForecastEnqueueFailedIfStillProcessingAsync(orderId); + await unitOfWork.CompleteAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "回写预报计费投递失败原因失败 OrderId={OrderId}", orderId); + } + } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffectPayload.cs b/src/Oms.Application/Messaging/OrderSideEffectPayload.cs new file mode 100644 index 0000000..8f629c5 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffectPayload.cs @@ -0,0 +1,34 @@ +namespace Oms.Application.Messaging; + +/// +/// 后置步骤入参。轨迹授权头只在执行时使用,不要写进日志。 +/// +public sealed class OrderSideEffectPayload +{ + public int OperatorUserId { get; set; } + + public string? PlatformCode { get; set; } + + public int? CouponInstanceId { get; set; } + + /// 为 true 时轨迹创建成功后追加 A0101。 + public bool AppendInitialTrack { get; set; } + + public string? Authorization { get; set; } + + public int? CustomerId { get; set; } + + public int? SenderAddressId { get; set; } + + public int? WarehouseId { get; set; } + + public string? PickupDirection { get; set; } + + public string? CarrierCode { get; set; } + + public string? CarrierService { get; set; } + + public DateTime? ExpectPickupTime { get; set; } + + public string? InboundMethod { get; set; } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffectQueue.cs b/src/Oms.Application/Messaging/OrderSideEffectQueue.cs new file mode 100644 index 0000000..1bd78a4 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffectQueue.cs @@ -0,0 +1,83 @@ +using Fake.DependencyInjection; +using Newtonsoft.Json; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Messaging; + +/// +/// 把创单后置步骤写入当前事务。事务回滚时这些行一起消失。 +/// +public sealed class OrderSideEffectQueue(IOrderSideEffectRepository repository) : IScopedDependency +{ + public const int MaxAttempts = 5; + + /// + /// 同一步骤已有 pending、running 或 done 时不再插入。用于 A0101、揽收和创单当下的自动触发。 + /// + public Task EnqueueOnceAsync( + int orderId, + string step, + OrderSideEffectPayload? payload, + CancellationToken cancellationToken = default) => + EnqueueAsync(orderId, step, payload, includeDone: true, cancellationToken); + + /// + /// 没有 pending 或 running 时才插入。已完成的步骤允许再入队,例如重新预报、排货后刷新轨迹。 + /// + public Task EnqueueIfIdleAsync( + int orderId, + string step, + OrderSideEffectPayload? payload, + CancellationToken cancellationToken = default) => + EnqueueAsync(orderId, step, payload, includeDone: false, cancellationToken); + + /// + /// 读取同一步骤最近一次入参。没有记录时返回 null。 + /// + public async Task FindLatestPayloadAsync( + int orderId, + string step, + CancellationToken cancellationToken = default) + { + var json = await repository.FindLatestPayloadJsonAsync(orderId, step, cancellationToken); + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return JsonConvert.DeserializeObject(json); + } + + private async Task EnqueueAsync( + int orderId, + string step, + OrderSideEffectPayload? payload, + bool includeDone, + CancellationToken cancellationToken) + { + if (orderId <= 0 || string.IsNullOrWhiteSpace(step)) + { + return; + } + + if (await repository.ExistsAsync(orderId, step, includeDone, cancellationToken)) + { + return; + } + + var now = DateTime.Now; + await repository.InsertAsync(new OrderSideEffect + { + OrderId = orderId, + Step = step, + Status = OrderSideEffectStatusCodes.Pending, + PayloadJson = payload == null ? null : JsonConvert.SerializeObject(payload), + AttemptCount = 0, + NextRunTime = now, + CreateTime = now, + UpdateTime = now + }, cancellationToken); + } +} diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index c24e699..1df839c 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -65,5 +65,6 @@ public class OmsApplicationModule : FakeModule context.Services.AddAiLlmClient(); context.Services.AddHostedService(); context.Services.AddHostedService(); + context.Services.AddHostedService(); } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs index 071ad64..8930e13 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs @@ -182,10 +182,7 @@ public sealed class OrderBatchJobProcessor( text = text[..512]; } - order.OrderStatus = OrderStatusCodes.Draft; - order.PredeclareFailReason = text; - order.UpdateTime = DateTime.Now; - await orderRepository.UpdateAsync(order, cancellationToken); + await orderRepository.UpdatePredeclareFailureAsync(order.Id, text, cancellationToken: cancellationToken); } private async Task RefreshJobSummaryAsync(int jobId, CancellationToken cancellationToken) diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index 34d536e..69243c9 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -17,6 +17,7 @@ public class AutoTriggerRuntimeService( OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, IOrderSideEffectEventPublisher sideEffectEventPublisher, + IOrderExceptionEventRepository orderExceptionEventRepository, ILogger logger) : IScopedDependency { private const string InterceptOperation = "拦截"; @@ -81,6 +82,97 @@ public class AutoTriggerRuntimeService( await FireAsync(order, timing, operatorUserId); } + /// + /// 预报计费完成后再评估。只跑依赖计费/渠道/费用的规则,以及草稿阶段没能打上的问题件。 + /// + public async Task FireSettledByOrderIdAsync(int orderId, int operatorUserId) + { + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null) + { + return; + } + + var context = await orderAutoTriggerService.BuildPostCommitContextAsync(order); + var triggers = await autoTriggerRepository.GetListAsync(x => x.Status == EnableStatus.Enabled); + var orderedTriggers = triggers + .Where(x => AutoTriggerTimingCodes.Matches(x.TriggerTiming, AutoTriggerTiming.OrderCreate)) + .Select(x => (Trigger: x, Priority: AutoTriggerRuleResolver.GetPriority(x))) + .OrderBy(x => x.Priority) + .ThenBy(x => x.Trigger.Id) + .Select(x => x.Trigger) + .ToList(); + + foreach (var trigger in orderedTriggers) + { + if (string.Equals(trigger.Operation?.Trim(), InterceptOperation, StringComparison.Ordinal)) + { + continue; + } + + var isProblem = string.Equals(trigger.Operation?.Trim(), "问题件", StringComparison.Ordinal); + if (!isProblem && !ReferencesSettlementFields(trigger.TriggerCondition)) + { + continue; + } + + if (isProblem && await AlreadyMarkedByTriggerAsync(order.Id, trigger.Id)) + { + continue; + } + + if (!AutoTriggerConditionMatcher.Matches(context, trigger.TriggerCondition)) + { + continue; + } + + try + { + await ExecuteTriggerAsync(order, trigger, operatorUserId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "计费后自动触发失败 OrderId={OrderId} TriggerId={TriggerId}", order.Id, trigger.Id); + } + } + } + + private static readonly string[] SettlementFieldTokens = + [ + "计费重", + "体积重", + "收货渠道", + "下游服务商", + "渠道预报编码", + "预报失败原因", + "费用类型", + "费用项", + "费用金额", + "费用币种", + "费用下游服务商", + "价格编号", + "计费阶段", + "价格生效时间" + ]; + + private static bool ReferencesSettlementFields(string? condition) + { + if (string.IsNullOrWhiteSpace(condition)) + { + return false; + } + + return SettlementFieldTokens.Any(token => condition.Contains(token, StringComparison.Ordinal)); + } + + private async Task AlreadyMarkedByTriggerAsync(int orderId, int triggerId) + { + var remark = $"自动触发#{triggerId}"; + var existed = await orderExceptionEventRepository.FirstOrDefaultAsync(x => + x.OrderId == orderId && x.Remark == remark); + return existed != null; + } + private async Task ExecuteTriggerAsync( Order order, AutoTrigger trigger, @@ -92,7 +184,7 @@ public class AutoTriggerRuntimeService( if (order.IsIntercept == 0) { order.IsIntercept = 1; - await orderRepository.UpdateAsync(order); + await orderRepository.UpdateInterceptFlagAsync(order.Id, 1); } return; diff --git a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs index 374b18a..430e184 100644 --- a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs +++ b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs @@ -42,9 +42,10 @@ public class OrderExceptionWorkflowService( int operatorUserId, bool sendNotification) { - if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus)) + var current = await orderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order; + if (!OrderExceptionStatusRules.CanMarkException(current.OrderStatus)) { - return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus); + return OrderExceptionStatusRules.GetForbiddenReason(current.OrderStatus); } IssueTemplate? template = null; @@ -56,14 +57,14 @@ public class OrderExceptionWorkflowService( IssueHandlerResolution handlerResolution; try { - handlerResolution = await issueHandlerResolver.ResolveAsync(order, template, request.HandlerNames); + handlerResolution = await issueHandlerResolver.ResolveAsync(current, template, request.HandlerNames); } catch (BusinessException ex) { return ex.Message; } - var context = IssuePlaceholderContext.FromOrder(order, request.IssueType, request.Title); + var context = IssuePlaceholderContext.FromOrder(current, request.IssueType, request.Title); var renderedTitle = IssuePlaceholderRenderer.Render(request.Title, context); var renderedContent = IssuePlaceholderRenderer.Render(request.Content, context); @@ -73,28 +74,37 @@ public class OrderExceptionWorkflowService( notifyMethod = IssueNotifyMethodCodes.InApp; } - var fromStatus = order.OrderStatus; + var fromStatus = current.OrderStatus; var isAppend = string.Equals(fromStatus, OrderStatusCodes.Problematic, StringComparison.OrdinalIgnoreCase); if (!isAppend) { - order.PreviousOrderStatus = fromStatus; - order.OrderStatus = OrderStatusCodes.Problematic; + current.PreviousOrderStatus = fromStatus; + current.OrderStatus = OrderStatusCodes.Problematic; } - order.HandleStatus = OrderHandleStatusCodes.Pending; - order.IssueType = request.IssueType; - order.IssueTitle = renderedTitle; - order.IssueContent = renderedContent; - order.IssueTemplateId = request.IssueTemplateId; - order.IssueRemark = request.Remark; - order.IssueTime = DateTime.Now; - order.ClaimType = null; - order.ClaimAmount = null; - order.ClaimCurrency = null; - order.ClaimReason = null; - order.ClaimTime = null; + current.HandleStatus = OrderHandleStatusCodes.Pending; + current.IssueType = request.IssueType; + current.IssueTitle = renderedTitle; + current.IssueContent = renderedContent; + current.IssueTemplateId = request.IssueTemplateId; + current.IssueRemark = request.Remark; + current.IssueTime = DateTime.Now; + current.ClaimType = null; + current.ClaimAmount = null; + current.ClaimCurrency = null; + current.ClaimReason = null; + current.ClaimTime = null; - await orderRepository.UpdateAsync(order); + await orderRepository.UpdateAsync(current); + order.OrderStatus = current.OrderStatus; + order.PreviousOrderStatus = current.PreviousOrderStatus; + order.HandleStatus = current.HandleStatus; + order.IssueType = current.IssueType; + order.IssueTitle = current.IssueTitle; + order.IssueContent = current.IssueContent; + order.IssueTemplateId = current.IssueTemplateId; + order.IssueRemark = current.IssueRemark; + order.IssueTime = current.IssueTime; await orderExceptionEventRepository.InsertAsync(new OrderExceptionEvent { OrderId = order.Id, @@ -120,7 +130,7 @@ public class OrderExceptionWorkflowService( ? handlerResolution.NotifyUserIds : null; await issueNotificationService.DispatchAsync( - order, + current, renderedTitle, renderedContent, notifyMethod, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 2d66718..3270344 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -170,11 +170,12 @@ public partial class OrderService var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); - // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 + // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收与订单同一事务记账,提交后各自执行。 if (input.SaveAsDraft) { var draftOutput = await BuildParcelCreateOutputAsync(order); - await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); + ApplyPickupPlan(input, draftOutput); + await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: false, operatorId); return draftOutput; } @@ -193,7 +194,8 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var output = await BuildParcelCreateOutputAsync(order); - await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); + ApplyPickupPlan(input, output); + await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: true, operatorId); return output; } catch (BusinessException) @@ -2284,7 +2286,7 @@ public partial class OrderService } /// - /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。 + /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货写入后置步骤,提交后执行。 /// internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null) { @@ -2329,38 +2331,7 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); - var publisher = LazyServiceProvider.GetRequiredService(); - if (!publisher.IsEnabled) - { - var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload - { - CouponInstanceId = order.CouponInstanceId - }); - await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); - } - else - { - var orderId = order.Id; - var couponInstanceId = order.CouponInstanceId; - await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); - } - - await ScheduleForecastSideEffectsAsync(order.Id, operatorId); - - // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 - var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); - if (refreshed != null - && string.Equals(refreshed.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(refreshed.PredeclareFailReason) - && !string.Equals( - refreshed.PredeclareFailReason, - ForecastBillingProcessingReason, - StringComparison.Ordinal)) - { - throw CreateDraftSavedBusinessException( - refreshed, - StripDraftSavedMarker(refreshed.PredeclareFailReason)); - } + await EnqueueDraftPredeclareSideEffectsAsync(order.Id, order.CouponInstanceId, operatorId); } catch (BusinessException ex) { @@ -2387,10 +2358,11 @@ public partial class OrderService private async Task MarkParcelDraftFailedAsync(Order order, string reason) { + var text = TrimPendingReason(reason); order.OrderStatus = OrderStatusCodes.Draft; - order.PredeclareFailReason = reason; + order.PredeclareFailReason = text; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdateAsync(order); + await OrderRepository.UpdatePredeclareFailureAsync(order.Id, text, order.IsRemote); } /// @@ -2823,10 +2795,23 @@ public partial class OrderService // ignore } - order.OrderStatus = OrderStatusCodes.Predeclared; order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdateAsync(order); + await OrderRepository.UpdateForecastCompletionAsync(order); + var persisted = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); + if (persisted != null) + { + order.OrderStatus = persisted.OrderStatus; + order.PredeclareFailReason = persisted.PredeclareFailReason; + order.PreviousOrderStatus = persisted.PreviousOrderStatus; + order.LabelTemplateId = persisted.LabelTemplateId; + } + else + { + order.OrderStatus = OrderStatusCodes.Predeclared; + } + + await EnqueueSettlementSideEffectsAsync(order.Id, operatorUserId); var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); await OperationLogWriter.TryWriteAsync( @@ -2846,93 +2831,6 @@ public partial class OrderService public int? CouponInstanceId { 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; - - 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); - }); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId)); - 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) - { - 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))); - } - - /// - /// 创单或草稿重新预报提交后,轨迹、面单、下单自动触发各自入队,不等预报计费。 - /// - 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, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId)); - 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)); - }); - } - private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); @@ -2945,19 +2843,7 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); } - private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) - { - 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); - } - - private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) + internal async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2969,7 +2855,7 @@ public partial class OrderService await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } - private async Task PublishForecastTrackAsync(int orderId) + private async Task PublishForecastTrackAsync(int orderId, bool appendInitialTrack, string? authorization) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2982,8 +2868,12 @@ public partial class OrderService ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); - request.AppendInitialTrackOnSuccess = true; - ApplyTrackAuthorization(request); + request.AppendInitialTrackOnSuccess = appendInitialTrack; + if (!string.IsNullOrWhiteSpace(authorization)) + { + request.Authorization = authorization.Trim(); + } + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } diff --git a/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs b/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs new file mode 100644 index 0000000..204230f --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs @@ -0,0 +1,317 @@ +using Fake.Application; +using Fake.UnitOfWork; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Oms.Application.Dtos.Orders; +using Oms.Application.Messaging; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private OrderSideEffectQueue SideEffectQueue => + LazyServiceProvider.GetRequiredService(); + + /// + /// 创单事务内写入后置步骤。保存草稿只写 9610 和揽收;确认下单再写预报、轨迹、面单和自动触发。 + /// + private async Task EnqueueCreateSideEffectsAsync( + int orderId, + OrderParcelCreateInput input, + bool includeForecast, + int operatorUserId) + { + var authorization = CaptureRequestAuthorization(); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.Customs9610, + new OrderSideEffectPayload { PlatformCode = input.PlatformCode }); + + if (includeForecast) + { + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.ForecastBilling, + new OrderSideEffectPayload + { + CouponInstanceId = input.CouponInstanceId, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.TrackUpsert, + new OrderSideEffectPayload + { + AppendInitialTrack = true, + Authorization = authorization, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.LabelGenerate, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.AutoTrigger, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + } + + if (IsDoorPickup(input) && ValidateDoorPickup(input) == null) + { + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.PickupAttach, + BuildPickupPayload(input)); + } + + await KickSideEffectsAsync(); + } + + /// + /// 草稿重新预报:预报计费可再次入队;轨迹 A0101、面单和创单触发已做过则跳过。 + /// + private async Task EnqueueDraftPredeclareSideEffectsAsync( + int orderId, + int? couponInstanceId, + int operatorUserId) + { + var authorization = CaptureRequestAuthorization(); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.ForecastBilling, + new OrderSideEffectPayload + { + CouponInstanceId = couponInstanceId, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.TrackUpsert, + new OrderSideEffectPayload + { + AppendInitialTrack = true, + Authorization = authorization, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.LabelGenerate, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.AutoTrigger, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await KickSideEffectsAsync(); + } + + /// + /// 预报计费成功后补轨迹渠道、系统面单,以及依赖计费结果的自动触发。 + /// + private async Task EnqueueSettlementSideEffectsAsync(int orderId, int operatorUserId) + { + var previousTrack = await SideEffectQueue.FindLatestPayloadAsync(orderId, OrderSideEffectSteps.TrackUpsert); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.TrackRefresh, + new OrderSideEffectPayload + { + AppendInitialTrack = false, + Authorization = previousTrack?.Authorization, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.LabelRefresh, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.AutoTriggerSettled, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await KickSideEffectsAsync(); + } + + private Task KickSideEffectsAsync() + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderSideEffect"); + return OrderSideEffectExecutor.ScheduleAfterCommitAsync(unitOfWorkManager, scopeFactory, logger); + } + + /// + /// 执行一条已抢占的后置步骤。由独立事务调用,失败抛出以便重试。 + /// + internal async Task ExecuteSideEffectAsync(OrderSideEffect row, CancellationToken cancellationToken = default) + { + var payload = string.IsNullOrWhiteSpace(row.PayloadJson) + ? new OrderSideEffectPayload() + : JsonConvert.DeserializeObject(row.PayloadJson) ?? new OrderSideEffectPayload(); + + switch (row.Step) + { + case OrderSideEffectSteps.Customs9610: + await PublishCustomsForOrderAsync(row.OrderId, payload.PlatformCode); + break; + case OrderSideEffectSteps.ForecastBilling: + await ExecuteForecastBillingStepAsync(row.OrderId, payload); + break; + case OrderSideEffectSteps.TrackUpsert: + await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: true, payload.Authorization); + break; + case OrderSideEffectSteps.TrackRefresh: + await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: false, payload.Authorization); + break; + case OrderSideEffectSteps.LabelGenerate: + case OrderSideEffectSteps.LabelRefresh: + await PublishForecastLabelAsync(row.OrderId, payload.OperatorUserId); + break; + case OrderSideEffectSteps.AutoTrigger: + await PublishForecastAutoTriggerAsync(row.OrderId, payload.OperatorUserId); + break; + case OrderSideEffectSteps.AutoTriggerSettled: + await PublishSettledAutoTriggerAsync(row.OrderId, payload.OperatorUserId); + break; + case OrderSideEffectSteps.PickupAttach: + await ExecutePickupSideEffectAsync(row.OrderId, payload); + break; + default: + LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.OrderSideEffect") + .LogWarning("未知的订单后置步骤 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); + break; + } + } + + private async Task ExecuteForecastBillingStepAsync(int orderId, OrderSideEffectPayload payload) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) + { + return; + } + + await EnqueueForecastBillingAsync(order, payload.CouponInstanceId, payload.OperatorUserId); + } + + private async Task PublishSettledAutoTriggerAsync(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.FireSettledByOrderIdAsync(orderId, operatorUserId); + } + + private async Task ExecutePickupSideEffectAsync(int orderId, OrderSideEffectPayload payload) + { + var input = new OrderParcelCreateInput + { + CustomerId = payload.CustomerId, + InboundMethod = payload.InboundMethod, + SenderAddressId = payload.SenderAddressId, + WarehouseId = payload.WarehouseId, + PickupDirection = payload.PickupDirection, + CarrierCode = payload.CarrierCode, + CarrierService = payload.CarrierService, + ExpectPickupTime = payload.ExpectPickupTime + }; + var output = new OrderParcelCreateOutput { OrderId = orderId }; + await TryAttachPickupAfterParcelCreateAsync(input, output); + if (string.IsNullOrWhiteSpace(output.PickupCreateError)) + { + return; + } + + if (output.PickupCreateError.Contains("已绑定", StringComparison.Ordinal) + || output.PickupCreateError.Contains("已在当前揽收单", StringComparison.Ordinal)) + { + return; + } + + throw new BusinessException(output.PickupCreateError); + } + + private void ApplyPickupPlan(OrderParcelCreateInput input, OrderParcelCreateOutput output) + { + if (!IsDoorPickup(input)) + { + return; + } + + var error = ValidateDoorPickup(input); + if (error != null) + { + output.PickupCreateError = error; + return; + } + + output.PickupQueued = true; + } + + private static bool IsDoorPickup(OrderParcelCreateInput input) => + string.Equals(input.InboundMethod?.Trim(), OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase); + + private static string? ValidateDoorPickup(OrderParcelCreateInput input) + { + if (input.SenderAddressId is not > 0) + { + return "上门揽收需选择揽收地址"; + } + + if (input.ExpectPickupTime is null || input.ExpectPickupTime == default) + { + return "上门揽收需填写期望揽收时间"; + } + + if (input.WarehouseId is not > 0) + { + return "上门揽收需选择仓库"; + } + + return null; + } + + private static OrderSideEffectPayload BuildPickupPayload(OrderParcelCreateInput input) => + new() + { + CustomerId = input.CustomerId, + SenderAddressId = input.SenderAddressId, + WarehouseId = input.WarehouseId, + PickupDirection = input.PickupDirection, + CarrierCode = input.CarrierCode, + CarrierService = input.CarrierService, + ExpectPickupTime = input.ExpectPickupTime, + InboundMethod = input.InboundMethod + }; + + private string? CaptureRequestAuthorization() + { + try + { + return LazyServiceProvider + .GetRequiredService() + .HttpContext? + .Request + .Headers + .Authorization + .ToString(); + } + catch (ObjectDisposedException) + { + return null; + } + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index eaccbe2..ff7c50b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -262,10 +262,10 @@ public partial class OrderService : ApplicationService } /// - /// 执行订单预报。 + /// 执行订单预报。校验通过后订单保持草稿并标记处理中,计费和排货在事务提交后执行。 /// /// 入参:订单操作参数。 - /// 无返回体,仅完成订单预报处理。 + /// 无返回体。预报结果随后写回订单状态和失败原因。 [HttpPost("/order/pre-declare")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task PreDeclareAsync(OrderActionInput input) diff --git a/src/Oms.Application/Workers/OrderSideEffectWorker.cs b/src/Oms.Application/Workers/OrderSideEffectWorker.cs new file mode 100644 index 0000000..9f28943 --- /dev/null +++ b/src/Oms.Application/Workers/OrderSideEffectWorker.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Oms.Application.Messaging; + +namespace Oms.Application.Workers; + +/// +/// 轮询已提交的创单后置步骤。进程在事务提交后、步骤执行前退出时,靠这里补跑。 +/// +public sealed class OrderSideEffectWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await OrderSideEffectExecutor.RecoverStuckForecastAsync(scopeFactory, logger, stoppingToken); + await OrderSideEffectExecutor.ProcessDueAsync(scopeFactory, logger, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "创单后置步骤轮询失败"); + } + + try + { + await Task.Delay(PollInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs new file mode 100644 index 0000000..9fc3d01 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs @@ -0,0 +1,44 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +/// +/// 创单后置步骤状态。同一订单、同一步骤不建唯一索引,是否重复入队由业务判断。 +/// +public static class OrderSideEffectStatusCodes +{ + public const string Pending = "pending"; + + public const string Running = "running"; + + public const string Done = "done"; + + public const string Failed = "failed"; +} + +/// +/// 创单/预报提交后要单独执行的步骤。 +/// +public static class OrderSideEffectSteps +{ + public const string Customs9610 = "customs-9610"; + + public const string ForecastBilling = "forecast-billing"; + + /// 创建轨迹订单并追加 A0101,同一订单只做一次。 + public const string TrackUpsert = "track-upsert"; + + /// 排货完成后按最新渠道再同步一次轨迹,不追加 A0101。 + public const string TrackRefresh = "track-refresh"; + + public const string LabelGenerate = "label-generate"; + + /// 排货完成后重做系统面单,覆盖创单时还没有渠道的那张。 + public const string LabelRefresh = "label-refresh"; + + /// 下单时机的自动触发。此时还没有计费重、应收和渠道。 + public const string AutoTrigger = "auto-trigger"; + + /// 预报计费成功后再跑依赖计费结果的规则,以及草稿阶段没能打上的问题件。 + public const string AutoTriggerSettled = "auto-trigger-settled"; + + public const string PickupAttach = "pickup-attach"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs new file mode 100644 index 0000000..feb5471 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs @@ -0,0 +1,30 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +/// +/// 与订单同一事务写入的后置步骤。事务回滚则步骤一起消失;提交后由后台执行,进程重启仍可继续。 +/// +[SugarTable("t_order_side_effect")] +public class OrderSideEffect : OmsAuditedAggregateRoot +{ + public int OrderId { get; set; } + + [SugarColumn(Length = 64)] + public string Step { get; set; } = string.Empty; + + [SugarColumn(Length = 32)] + public string Status { get; set; } = string.Empty; + + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? PayloadJson { get; set; } + + public int AttemptCount { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? NextRunTime { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? LastError { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index c5d8ab0..cf35dfd 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -137,4 +137,23 @@ public interface IOrderRepository : IRepository /// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。 /// Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default); + + /// + /// 预报失败时只写状态和原因,不覆盖渠道、面单模板、问题件字段。 + /// + Task UpdatePredeclareFailureAsync( + int orderId, + string reason, + byte? isRemote = null, + CancellationToken cancellationToken = default); + + /// + /// 预报计费成功时只写计费/排货字段。订单已是问题件或理赔件时不改状态,只把异常前状态改成已预报。 + /// + Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default); + + /// + /// 只更新拦截标记。 + /// + Task UpdateInterceptFlagAsync(int orderId, byte isIntercept, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs new file mode 100644 index 0000000..7d7e724 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs @@ -0,0 +1,55 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface IOrderSideEffectRepository : IRepository +{ + /// + /// 是否已有未结束(以及可选的已完成)步骤。用于避免重复入队。 + /// + Task ExistsAsync( + int orderId, + string step, + bool includeDone, + CancellationToken cancellationToken = default); + + Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default); + + /// + /// 取同一步骤最近一条入参,用于把创单时捕获的轨迹授权头带到排货后的刷新。 + /// + Task FindLatestPayloadJsonAsync( + int orderId, + string step, + CancellationToken cancellationToken = default); + + /// + /// 仅当当前仍是 pending 时改为 running,并增加尝试次数。 + /// + Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default); + + Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default); + + Task MarkRetryAsync( + int id, + string status, + string? lastError, + DateTime? nextRunTime, + DateTime now, + CancellationToken cancellationToken = default); + + /// + /// 执行过程中进程退出时,把长时间停在 running 的步骤放回 pending。 + /// + Task ResetStaleRunningAsync(DateTime staleBefore, DateTime now, CancellationToken cancellationToken = default); + + /// + /// 仍显示「预报计费/排货处理中」、且没有待执行、执行中或已完成预报步骤的订单。 + /// + Task> ListStuckForecastOrderIdsAsync( + string processingReason, + DateTime olderThan, + int take, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index c85d43f..f9ad549 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1505,4 +1505,85 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .Where(x => x.OrderStatus != OrderStatusCodes.Deleted) .ToListAsync(cancellationToken); } + + public async Task UpdatePredeclareFailureAsync( + int orderId, + string reason, + byte? isRemote = null, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var now = DateTime.Now; + var update = db.Updateable() + .SetColumns(x => x.OrderStatus == OrderStatusCodes.Draft) + .SetColumns(x => x.PredeclareFailReason == reason) + .SetColumns(x => x.UpdateTime == now); + if (isRemote.HasValue) + { + var remote = isRemote.Value; + update = update.SetColumns(x => x.IsRemote == remote); + } + + await update.Where(x => x.Id == orderId).ExecuteCommandAsync(cancellationToken); + } + + public async Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var now = DateTime.Now; + await db.Updateable() + .SetColumns(x => x.IsRemote == order.IsRemote) + .SetColumns(x => x.ChannelCode == order.ChannelCode) + .SetColumns(x => x.DownstreamSupplierCode == order.DownstreamSupplierCode) + .SetColumns(x => x.DownstreamSupplierName == order.DownstreamSupplierName) + .SetColumns(x => x.DownstreamAppKey == order.DownstreamAppKey) + .SetColumns(x => x.DownstreamAppId == order.DownstreamAppId) + .SetColumns(x => x.ChannelForecastCode == order.ChannelForecastCode) + .SetColumns(x => x.DownstreamServiceNo == order.DownstreamServiceNo) + .SetColumns(x => x.TrackNo == order.TrackNo) + .SetColumns(x => x.AllocationPendingReason == order.AllocationPendingReason) + .SetColumns(x => x.AllocationScheme == order.AllocationScheme) + .SetColumns(x => x.AllocationTraceJson == order.AllocationTraceJson) + .SetColumns(x => x.AllocationTraceTime == order.AllocationTraceTime) + .SetColumns(x => x.PredeclareFailReason == string.Empty) + .SetColumns(x => x.MemberLevelId == order.MemberLevelId) + .SetColumns(x => x.PriceTierKey == order.PriceTierKey) + .SetColumns(x => x.ReceivableSubtotal == order.ReceivableSubtotal) + .SetColumns(x => x.LevelDiscountAmount == order.LevelDiscountAmount) + .SetColumns(x => x.CouponInstanceId == order.CouponInstanceId) + .SetColumns(x => x.CouponDiscountAmount == order.CouponDiscountAmount) + .SetColumns(x => x.PayableAmount == order.PayableAmount) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == order.Id) + .ExecuteCommandAsync(cancellationToken); + + await db.Updateable() + .SetColumns(x => x.OrderStatus == OrderStatusCodes.Predeclared) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == order.Id + && x.OrderStatus != OrderStatusCodes.Problematic + && x.OrderStatus != OrderStatusCodes.Claimed) + .ExecuteCommandAsync(cancellationToken); + + await db.Updateable() + .SetColumns(x => x.PreviousOrderStatus == OrderStatusCodes.Predeclared) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == order.Id + && (x.OrderStatus == OrderStatusCodes.Problematic + || x.OrderStatus == OrderStatusCodes.Claimed)) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task UpdateInterceptFlagAsync( + int orderId, + byte isIntercept, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => x.IsIntercept == isIntercept) + .SetColumns(x => x.UpdateTime == DateTime.Now) + .Where(x => x.Id == orderId) + .ExecuteCommandAsync(cancellationToken); + } } diff --git a/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs new file mode 100644 index 0000000..4d6290a --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs @@ -0,0 +1,153 @@ +using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Consts; +using SqlSugar; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Orders; + +public class OrderSideEffectRepository : OmsSqlSugarRepository, IOrderSideEffectRepository, IScopedDependency +{ + public async Task ExistsAsync( + int orderId, + string step, + bool includeDone, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var statuses = includeDone + ? new[] + { + OrderSideEffectStatusCodes.Pending, + OrderSideEffectStatusCodes.Running, + OrderSideEffectStatusCodes.Done + } + : new[] + { + OrderSideEffectStatusCodes.Pending, + OrderSideEffectStatusCodes.Running + }; + + return await db.Queryable() + .AnyAsync( + x => x.OrderId == orderId + && x.Step == step + && statuses.Contains(x.Status) + && x.IsDeleted == false, + cancellationToken); + } + + public async Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.Status == OrderSideEffectStatusCodes.Pending + && x.IsDeleted == false + && (x.NextRunTime == null || x.NextRunTime <= now)) + .OrderBy(x => x.Id) + .Take(take) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + } + + public async Task FindLatestPayloadJsonAsync( + int orderId, + string step, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var rows = await db.Queryable() + .Where(x => x.OrderId == orderId && x.Step == step && x.IsDeleted == false) + .OrderByDescending(x => x.Id) + .Take(1) + .Select(x => x.PayloadJson) + .ToListAsync(cancellationToken); + return rows.FirstOrDefault(); + } + + public async Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var affected = await db.Updateable() + .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Running) + .SetColumns(x => x.UpdateTime == now) + .SetColumns(x => x.AttemptCount == x.AttemptCount + 1) + .Where(x => x.Id == id + && x.Status == OrderSideEffectStatusCodes.Pending + && x.IsDeleted == false) + .ExecuteCommandAsync(cancellationToken); + return affected == 1; + } + + public async Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Done) + .SetColumns(x => x.UpdateTime == now) + .SetColumns(x => x.LastError == null) + .Where(x => x.Id == id) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task MarkRetryAsync( + int id, + string status, + string? lastError, + DateTime? nextRunTime, + DateTime now, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => x.Status == status) + .SetColumns(x => x.LastError == lastError) + .SetColumns(x => x.NextRunTime == nextRunTime) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == id) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task ResetStaleRunningAsync( + DateTime staleBefore, + DateTime now, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Updateable() + .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Pending) + .SetColumns(x => x.NextRunTime == now) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Status == OrderSideEffectStatusCodes.Running + && x.IsDeleted == false + && x.UpdateTime < staleBefore) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task> ListStuckForecastOrderIdsAsync( + string processingReason, + DateTime olderThan, + int take, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(order => order.IsDeleted == false + && order.PredeclareFailReason == processingReason + && order.UpdateTime < olderThan + && order.UpdateTime > DateTime.MinValue) + .Where(order => !SqlSugar.SqlFunc.Subqueryable() + .Where(effect => effect.OrderId == order.Id + && effect.Step == OrderSideEffectSteps.ForecastBilling + && effect.IsDeleted == false + && (effect.Status == OrderSideEffectStatusCodes.Pending + || effect.Status == OrderSideEffectStatusCodes.Running + || effect.Status == OrderSideEffectStatusCodes.Done)) + .Any()) + .OrderBy(order => order.Id) + .Take(take) + .Select(order => order.Id) + .ToListAsync(cancellationToken); + } +} From 5fd25d479c10ff06535947505d26c4b13d2ccd87 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 09:05:26 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E5=8E=BB=E6=8E=89=E5=88=9B=E5=8D=95?= =?UTF-8?q?=E5=90=8E=E7=BD=AE=E6=AD=A5=E9=AA=A4=E8=A1=A8=EF=BC=8C=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E8=AE=A2=E5=8D=95=E6=94=B9=E5=9C=A8=E6=8E=92=E8=B4=A7?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E5=90=8E=E5=88=9B=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回到创单提交后各自投递;并在下单接口注释同步与 MQ 步骤。 Co-authored-by: Cursor --- db.sql | 21 -- sql/drop_order_side_effect.sql | 20 ++ sql/migrate_order_side_effect.sql | 39 --- .../Messaging/OmsAfterCommit.cs | 59 ++-- .../Messaging/OrderSideEffectExecutor.cs | 202 ----------- .../Messaging/OrderSideEffectPayload.cs | 34 -- .../Messaging/OrderSideEffectQueue.cs | 83 ----- src/Oms.Application/OmsApplicationModule.cs | 1 - .../Orders/Batch/OrderBatchJobProcessor.cs | 5 +- .../Issues/AutoTriggerRuntimeService.cs | 94 +----- .../Issues/OrderExceptionWorkflowService.cs | 52 ++- .../Orders/OrderService.ParcelBilling.cs | 195 +++++++++-- .../Orders/OrderService.SideEffects.cs | 317 ------------------ .../Services/Orders/OrderService.cs | 34 +- .../Workers/OrderSideEffectWorker.cs | 41 --- .../Consts/OrderSideEffectCodes.cs | 44 --- .../Entities/OrderSideEffect.cs | 30 -- .../Repositories/IOrderRepository.cs | 19 -- .../IOrderSideEffectRepository.cs | 55 --- .../Orders/OrderRepository.cs | 81 ----- .../Orders/OrderSideEffectRepository.cs | 153 --------- 21 files changed, 268 insertions(+), 1311 deletions(-) create mode 100644 sql/drop_order_side_effect.sql delete mode 100644 sql/migrate_order_side_effect.sql delete mode 100644 src/Oms.Application/Messaging/OrderSideEffectExecutor.cs delete mode 100644 src/Oms.Application/Messaging/OrderSideEffectPayload.cs delete mode 100644 src/Oms.Application/Messaging/OrderSideEffectQueue.cs delete mode 100644 src/Oms.Application/Services/Orders/OrderService.SideEffects.cs delete mode 100644 src/Oms.Application/Workers/OrderSideEffectWorker.cs delete mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs delete mode 100644 src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs delete mode 100644 src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs delete mode 100644 src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs diff --git a/db.sql b/db.sql index 199ec6e..3f1111e 100644 --- a/db.sql +++ b/db.sql @@ -101,7 +101,6 @@ DROP TABLE IF EXISTS `t_order_weight`; DROP TABLE IF EXISTS `t_order_declaration`; DROP TABLE IF EXISTS `t_order_recipient`; DROP TABLE IF EXISTS `t_order_sender`; -DROP TABLE IF EXISTS `t_order_side_effect`; DROP TABLE IF EXISTS `t_order_batch_job_item`; DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_template`; @@ -307,26 +306,6 @@ CREATE TABLE `t_order_template` ( KEY `idx_ord_tpl_customer_type` (`CustomerId`, `Type`) ) COMMENT='下单模板表'; -CREATE TABLE `t_order_side_effect` ( - `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', - `OrderId` INT NOT NULL COMMENT '订单 Id', - `Step` VARCHAR(64) NOT NULL COMMENT '步骤:customs-9610/forecast-billing/track-upsert 等', - `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/running/done/failed', - `PayloadJson` TEXT NULL COMMENT '步骤入参', - `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT '已尝试次数', - `NextRunTime` DATETIME NULL COMMENT '下次可执行时间', - `LastError` VARCHAR(512) NULL COMMENT '最近一次失败原因', - `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', - `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', - `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', - `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `UpdateUserId` INT NULL COMMENT '更新人', - `UpdateTime` DATETIME NULL COMMENT '更新时间', - PRIMARY KEY (`Id`), - KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), - KEY `idx_order_side_effect_order` (`OrderId`, `Step`) -) COMMENT='创单后置步骤'; - CREATE TABLE `t_order_batch_job` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等', diff --git a/sql/drop_order_side_effect.sql b/sql/drop_order_side_effect.sql new file mode 100644 index 0000000..04869f6 --- /dev/null +++ b/sql/drop_order_side_effect.sql @@ -0,0 +1,20 @@ +-- 删除创单后置步骤表(已不再使用)。可重复执行。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_side_effect' +); + +SET @sql := IF( + @exists > 0, + 'DROP TABLE `t_order_side_effect`', + 'SELECT ''t_order_side_effect already dropped'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_order_side_effect.sql b/sql/migrate_order_side_effect.sql deleted file mode 100644 index 20fbf7a..0000000 --- a/sql/migrate_order_side_effect.sql +++ /dev/null @@ -1,39 +0,0 @@ --- 创单后置步骤表:与订单同一事务写入,提交后由后台执行。 --- 可重复执行。不建唯一索引。 - -SET @db := DATABASE(); - -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.TABLES - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_order_side_effect' -); - -SET @sql := IF( - @exists = 0, - 'CREATE TABLE `t_order_side_effect` ( - `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', - `OrderId` INT NOT NULL COMMENT ''订单 Id'', - `Step` VARCHAR(64) NOT NULL COMMENT ''步骤:customs-9610/forecast-billing/track-upsert 等'', - `Status` VARCHAR(32) NOT NULL DEFAULT ''pending'' COMMENT ''pending/running/done/failed'', - `PayloadJson` TEXT NULL COMMENT ''步骤入参,含轨迹授权头,日志中不要打印全文'', - `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT ''已尝试次数'', - `NextRunTime` DATETIME NULL COMMENT ''下次可执行时间'', - `LastError` VARCHAR(512) NULL COMMENT ''最近一次失败原因'', - `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', - `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', - `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', - `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', - `UpdateUserId` INT NULL COMMENT ''更新人'', - `UpdateTime` DATETIME NULL COMMENT ''更新时间'', - PRIMARY KEY (`Id`), - KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), - KEY `idx_order_side_effect_order` (`OrderId`, `Step`) - ) COMMENT=''创单后置步骤''', - 'SELECT ''t_order_side_effect already exists'' AS info' -); - -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs index deda445..9b31ee7 100644 --- a/src/Oms.Application/Messaging/OmsAfterCommit.cs +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -1,9 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Fake.UnitOfWork; namespace Oms.Application.Messaging; /// -/// 当前事务提交并释放后再执行。回滚不会执行。 +/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 /// internal static class OmsAfterCommit { @@ -12,36 +14,13 @@ internal static class OmsAfterCommit var current = unitOfWorkManager.Current; if (current != null && !current.IsCompleted) { - // OnCompleted 仍处在业务事务里,此时写 Outbox 征用不到连接。 - // 等工作单元成功释放后再开独立事务。回滚时 IsCompleted 为 false,不跑。 + // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 + // 等工作单元释放后再开独立事务。 EventHandler handler = null!; handler = (_, _) => { current.Disposed -= handler; - if (!current.IsCompleted) - { - return; - } - - var suppressed = ExecutionContext.SuppressFlow(); - try - { - _ = Task.Run(async () => - { - try - { - await action(); - } - catch - { - // 具体步骤在执行器里记日志。这里避免未观察的任务异常。 - } - }); - } - finally - { - suppressed.Dispose(); - } + _ = Task.Run(action); }; current.Disposed += handler; return Task.CompletedTask; @@ -49,4 +28,30 @@ internal static class OmsAfterCommit 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/OrderSideEffectExecutor.cs b/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs deleted file mode 100644 index bf46bc5..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs +++ /dev/null @@ -1,202 +0,0 @@ -using Fake.Application; -using Fake.DependencyInjection; -using Fake.UnitOfWork; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Oms.Application.Services.Orders; -using Oms.Domain.OrdersAggregate.Consts; -using Oms.Domain.OrdersAggregate.Repositories; - -namespace Oms.Application.Messaging; - -/// -/// 每一步后置任务单独开事务执行。失败按次数重试,超过次数记失败。 -/// -public sealed class OrderSideEffectExecutor( - IOrderSideEffectRepository repository, - IUnitOfWorkManager unitOfWorkManager, - OrderService orderService, - ILogger logger) : IScopedDependency -{ - private static readonly TimeSpan StaleRunning = TimeSpan.FromMinutes(2); - - /// - /// 当前事务提交成功后再执行到期步骤。回滚不会触发。 - /// - public static Task ScheduleAfterCommitAsync( - IUnitOfWorkManager unitOfWorkManager, - IServiceScopeFactory scopeFactory, - ILogger logger) - { - return OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => ProcessDueAsync(scopeFactory, logger)); - } - - /// - /// 捞出到期步骤并逐条执行。可重复调用,抢占失败的步骤会被跳过。 - /// - public static async Task ProcessDueAsync( - IServiceScopeFactory scopeFactory, - ILogger logger, - CancellationToken cancellationToken = default) - { - List ids; - try - { - using var scope = scopeFactory.CreateScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - var now = DateTime.Now; - await repository.ResetStaleRunningAsync(now.Subtract(StaleRunning), now, cancellationToken); - ids = await repository.ListDueIdsAsync(30, now, cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError(ex, "捞取创单后置步骤失败"); - return; - } - - foreach (var id in ids) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - try - { - using var scope = scopeFactory.CreateScope(); - var executor = scope.ServiceProvider.GetRequiredService(); - await executor.ExecuteAsync(id, cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "订单后置步骤执行失败 SideEffectId={SideEffectId}", id); - } - } - } - - /// - /// 仍停在「预报计费/排货处理中」且没有未完成预报步骤的订单,补一条预报计费步骤。 - /// - public static async Task RecoverStuckForecastAsync( - IServiceScopeFactory scopeFactory, - ILogger logger, - CancellationToken cancellationToken = default) - { - try - { - using var scope = scopeFactory.CreateScope(); - var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - var repository = scope.ServiceProvider.GetRequiredService(); - var queue = scope.ServiceProvider.GetRequiredService(); - var olderThan = DateTime.Now.AddSeconds(-90); - var orderIds = await repository.ListStuckForecastOrderIdsAsync( - OrderService.ForecastBillingProcessingReason, - olderThan, - 20, - cancellationToken); - foreach (var orderId in orderIds) - { - await queue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.ForecastBilling, - new OrderSideEffectPayload(), - cancellationToken); - } - - await unitOfWork.CompleteAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "补投递卡住的预报计费失败"); - } - } - - private async Task ExecuteAsync(int id, CancellationToken cancellationToken) - { - var claimed = await ClaimAsync(id, cancellationToken); - if (!claimed) - { - return; - } - - var row = await repository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - if (row == null) - { - return; - } - - try - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); - await orderService.ExecuteSideEffectAsync(row, cancellationToken); - await repository.MarkDoneAsync(row.Id, DateTime.Now, cancellationToken); - await unitOfWork.CompleteAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "订单后置步骤失败 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); - var exhausted = await MarkRetryAsync(row, ex, cancellationToken); - if (exhausted - && string.Equals(row.Step, OrderSideEffectSteps.ForecastBilling, StringComparison.Ordinal)) - { - await StampForecastEnqueueFailureAsync(row.OrderId, cancellationToken); - } - } - } - - private async Task ClaimAsync(int id, CancellationToken cancellationToken) - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - var claimed = await repository.TryClaimAsync(id, DateTime.Now, cancellationToken); - await unitOfWork.CompleteAsync(cancellationToken); - return claimed; - } - - private async Task MarkRetryAsync( - Domain.OrdersAggregate.Entities.OrderSideEffect row, - Exception ex, - CancellationToken cancellationToken) - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - var latest = await repository.FirstOrDefaultAsync(x => x.Id == row.Id, cancellationToken); - var attempts = latest?.AttemptCount ?? row.AttemptCount; - var exhausted = attempts >= OrderSideEffectQueue.MaxAttempts; - var message = ex is BusinessException business ? business.Message : ex.Message; - if (string.IsNullOrWhiteSpace(message)) - { - message = ex.GetType().Name; - } - - if (message.Length > 512) - { - message = message[..512]; - } - - var now = DateTime.Now; - await repository.MarkRetryAsync( - row.Id, - exhausted ? OrderSideEffectStatusCodes.Failed : OrderSideEffectStatusCodes.Pending, - message, - exhausted ? null : now.AddSeconds(15 * Math.Max(1, attempts)), - now, - cancellationToken); - await unitOfWork.CompleteAsync(cancellationToken); - return exhausted; - } - - private async Task StampForecastEnqueueFailureAsync(int orderId, CancellationToken cancellationToken) - { - try - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - await orderService.MarkForecastEnqueueFailedIfStillProcessingAsync(orderId); - await unitOfWork.CompleteAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "回写预报计费投递失败原因失败 OrderId={OrderId}", orderId); - } - } -} diff --git a/src/Oms.Application/Messaging/OrderSideEffectPayload.cs b/src/Oms.Application/Messaging/OrderSideEffectPayload.cs deleted file mode 100644 index 8f629c5..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffectPayload.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Oms.Application.Messaging; - -/// -/// 后置步骤入参。轨迹授权头只在执行时使用,不要写进日志。 -/// -public sealed class OrderSideEffectPayload -{ - public int OperatorUserId { get; set; } - - public string? PlatformCode { get; set; } - - public int? CouponInstanceId { get; set; } - - /// 为 true 时轨迹创建成功后追加 A0101。 - public bool AppendInitialTrack { get; set; } - - public string? Authorization { get; set; } - - public int? CustomerId { get; set; } - - public int? SenderAddressId { get; set; } - - public int? WarehouseId { get; set; } - - public string? PickupDirection { get; set; } - - public string? CarrierCode { get; set; } - - public string? CarrierService { get; set; } - - public DateTime? ExpectPickupTime { get; set; } - - public string? InboundMethod { get; set; } -} diff --git a/src/Oms.Application/Messaging/OrderSideEffectQueue.cs b/src/Oms.Application/Messaging/OrderSideEffectQueue.cs deleted file mode 100644 index 1bd78a4..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffectQueue.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Fake.DependencyInjection; -using Newtonsoft.Json; -using Oms.Domain.OrdersAggregate.Consts; -using Oms.Domain.OrdersAggregate.Entities; -using Oms.Domain.OrdersAggregate.Repositories; - -namespace Oms.Application.Messaging; - -/// -/// 把创单后置步骤写入当前事务。事务回滚时这些行一起消失。 -/// -public sealed class OrderSideEffectQueue(IOrderSideEffectRepository repository) : IScopedDependency -{ - public const int MaxAttempts = 5; - - /// - /// 同一步骤已有 pending、running 或 done 时不再插入。用于 A0101、揽收和创单当下的自动触发。 - /// - public Task EnqueueOnceAsync( - int orderId, - string step, - OrderSideEffectPayload? payload, - CancellationToken cancellationToken = default) => - EnqueueAsync(orderId, step, payload, includeDone: true, cancellationToken); - - /// - /// 没有 pending 或 running 时才插入。已完成的步骤允许再入队,例如重新预报、排货后刷新轨迹。 - /// - public Task EnqueueIfIdleAsync( - int orderId, - string step, - OrderSideEffectPayload? payload, - CancellationToken cancellationToken = default) => - EnqueueAsync(orderId, step, payload, includeDone: false, cancellationToken); - - /// - /// 读取同一步骤最近一次入参。没有记录时返回 null。 - /// - public async Task FindLatestPayloadAsync( - int orderId, - string step, - CancellationToken cancellationToken = default) - { - var json = await repository.FindLatestPayloadJsonAsync(orderId, step, cancellationToken); - if (string.IsNullOrWhiteSpace(json)) - { - return null; - } - - return JsonConvert.DeserializeObject(json); - } - - private async Task EnqueueAsync( - int orderId, - string step, - OrderSideEffectPayload? payload, - bool includeDone, - CancellationToken cancellationToken) - { - if (orderId <= 0 || string.IsNullOrWhiteSpace(step)) - { - return; - } - - if (await repository.ExistsAsync(orderId, step, includeDone, cancellationToken)) - { - return; - } - - var now = DateTime.Now; - await repository.InsertAsync(new OrderSideEffect - { - OrderId = orderId, - Step = step, - Status = OrderSideEffectStatusCodes.Pending, - PayloadJson = payload == null ? null : JsonConvert.SerializeObject(payload), - AttemptCount = 0, - NextRunTime = now, - CreateTime = now, - UpdateTime = now - }, cancellationToken); - } -} diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index 1df839c..c24e699 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -65,6 +65,5 @@ public class OmsApplicationModule : FakeModule context.Services.AddAiLlmClient(); context.Services.AddHostedService(); context.Services.AddHostedService(); - context.Services.AddHostedService(); } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs index 8930e13..071ad64 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs @@ -182,7 +182,10 @@ public sealed class OrderBatchJobProcessor( text = text[..512]; } - await orderRepository.UpdatePredeclareFailureAsync(order.Id, text, cancellationToken: cancellationToken); + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = text; + order.UpdateTime = DateTime.Now; + await orderRepository.UpdateAsync(order, cancellationToken); } private async Task RefreshJobSummaryAsync(int jobId, CancellationToken cancellationToken) diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index 69243c9..34d536e 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -17,7 +17,6 @@ public class AutoTriggerRuntimeService( OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, IOrderSideEffectEventPublisher sideEffectEventPublisher, - IOrderExceptionEventRepository orderExceptionEventRepository, ILogger logger) : IScopedDependency { private const string InterceptOperation = "拦截"; @@ -82,97 +81,6 @@ public class AutoTriggerRuntimeService( await FireAsync(order, timing, operatorUserId); } - /// - /// 预报计费完成后再评估。只跑依赖计费/渠道/费用的规则,以及草稿阶段没能打上的问题件。 - /// - public async Task FireSettledByOrderIdAsync(int orderId, int operatorUserId) - { - var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null) - { - return; - } - - var context = await orderAutoTriggerService.BuildPostCommitContextAsync(order); - var triggers = await autoTriggerRepository.GetListAsync(x => x.Status == EnableStatus.Enabled); - var orderedTriggers = triggers - .Where(x => AutoTriggerTimingCodes.Matches(x.TriggerTiming, AutoTriggerTiming.OrderCreate)) - .Select(x => (Trigger: x, Priority: AutoTriggerRuleResolver.GetPriority(x))) - .OrderBy(x => x.Priority) - .ThenBy(x => x.Trigger.Id) - .Select(x => x.Trigger) - .ToList(); - - foreach (var trigger in orderedTriggers) - { - if (string.Equals(trigger.Operation?.Trim(), InterceptOperation, StringComparison.Ordinal)) - { - continue; - } - - var isProblem = string.Equals(trigger.Operation?.Trim(), "问题件", StringComparison.Ordinal); - if (!isProblem && !ReferencesSettlementFields(trigger.TriggerCondition)) - { - continue; - } - - if (isProblem && await AlreadyMarkedByTriggerAsync(order.Id, trigger.Id)) - { - continue; - } - - if (!AutoTriggerConditionMatcher.Matches(context, trigger.TriggerCondition)) - { - continue; - } - - try - { - await ExecuteTriggerAsync(order, trigger, operatorUserId); - } - catch (Exception ex) - { - logger.LogWarning(ex, "计费后自动触发失败 OrderId={OrderId} TriggerId={TriggerId}", order.Id, trigger.Id); - } - } - } - - private static readonly string[] SettlementFieldTokens = - [ - "计费重", - "体积重", - "收货渠道", - "下游服务商", - "渠道预报编码", - "预报失败原因", - "费用类型", - "费用项", - "费用金额", - "费用币种", - "费用下游服务商", - "价格编号", - "计费阶段", - "价格生效时间" - ]; - - private static bool ReferencesSettlementFields(string? condition) - { - if (string.IsNullOrWhiteSpace(condition)) - { - return false; - } - - return SettlementFieldTokens.Any(token => condition.Contains(token, StringComparison.Ordinal)); - } - - private async Task AlreadyMarkedByTriggerAsync(int orderId, int triggerId) - { - var remark = $"自动触发#{triggerId}"; - var existed = await orderExceptionEventRepository.FirstOrDefaultAsync(x => - x.OrderId == orderId && x.Remark == remark); - return existed != null; - } - private async Task ExecuteTriggerAsync( Order order, AutoTrigger trigger, @@ -184,7 +92,7 @@ public class AutoTriggerRuntimeService( if (order.IsIntercept == 0) { order.IsIntercept = 1; - await orderRepository.UpdateInterceptFlagAsync(order.Id, 1); + await orderRepository.UpdateAsync(order); } return; diff --git a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs index 430e184..374b18a 100644 --- a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs +++ b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs @@ -42,10 +42,9 @@ public class OrderExceptionWorkflowService( int operatorUserId, bool sendNotification) { - var current = await orderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order; - if (!OrderExceptionStatusRules.CanMarkException(current.OrderStatus)) + if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus)) { - return OrderExceptionStatusRules.GetForbiddenReason(current.OrderStatus); + return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus); } IssueTemplate? template = null; @@ -57,14 +56,14 @@ public class OrderExceptionWorkflowService( IssueHandlerResolution handlerResolution; try { - handlerResolution = await issueHandlerResolver.ResolveAsync(current, template, request.HandlerNames); + handlerResolution = await issueHandlerResolver.ResolveAsync(order, template, request.HandlerNames); } catch (BusinessException ex) { return ex.Message; } - var context = IssuePlaceholderContext.FromOrder(current, request.IssueType, request.Title); + var context = IssuePlaceholderContext.FromOrder(order, request.IssueType, request.Title); var renderedTitle = IssuePlaceholderRenderer.Render(request.Title, context); var renderedContent = IssuePlaceholderRenderer.Render(request.Content, context); @@ -74,37 +73,28 @@ public class OrderExceptionWorkflowService( notifyMethod = IssueNotifyMethodCodes.InApp; } - var fromStatus = current.OrderStatus; + var fromStatus = order.OrderStatus; var isAppend = string.Equals(fromStatus, OrderStatusCodes.Problematic, StringComparison.OrdinalIgnoreCase); if (!isAppend) { - current.PreviousOrderStatus = fromStatus; - current.OrderStatus = OrderStatusCodes.Problematic; + order.PreviousOrderStatus = fromStatus; + order.OrderStatus = OrderStatusCodes.Problematic; } - current.HandleStatus = OrderHandleStatusCodes.Pending; - current.IssueType = request.IssueType; - current.IssueTitle = renderedTitle; - current.IssueContent = renderedContent; - current.IssueTemplateId = request.IssueTemplateId; - current.IssueRemark = request.Remark; - current.IssueTime = DateTime.Now; - current.ClaimType = null; - current.ClaimAmount = null; - current.ClaimCurrency = null; - current.ClaimReason = null; - current.ClaimTime = null; + order.HandleStatus = OrderHandleStatusCodes.Pending; + order.IssueType = request.IssueType; + order.IssueTitle = renderedTitle; + order.IssueContent = renderedContent; + order.IssueTemplateId = request.IssueTemplateId; + order.IssueRemark = request.Remark; + order.IssueTime = DateTime.Now; + order.ClaimType = null; + order.ClaimAmount = null; + order.ClaimCurrency = null; + order.ClaimReason = null; + order.ClaimTime = null; - await orderRepository.UpdateAsync(current); - order.OrderStatus = current.OrderStatus; - order.PreviousOrderStatus = current.PreviousOrderStatus; - order.HandleStatus = current.HandleStatus; - order.IssueType = current.IssueType; - order.IssueTitle = current.IssueTitle; - order.IssueContent = current.IssueContent; - order.IssueTemplateId = current.IssueTemplateId; - order.IssueRemark = current.IssueRemark; - order.IssueTime = current.IssueTime; + await orderRepository.UpdateAsync(order); await orderExceptionEventRepository.InsertAsync(new OrderExceptionEvent { OrderId = order.Id, @@ -130,7 +120,7 @@ public class OrderExceptionWorkflowService( ? handlerResolution.NotifyUserIds : null; await issueNotificationService.DispatchAsync( - current, + order, renderedTitle, renderedContent, notifyMethod, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 3270344..f1f1a5f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -170,12 +170,11 @@ public partial class OrderService var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); - // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收与订单同一事务记账,提交后各自执行。 + // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 if (input.SaveAsDraft) { var draftOutput = await BuildParcelCreateOutputAsync(order); - ApplyPickupPlan(input, draftOutput); - await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: false, operatorId); + await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); return draftOutput; } @@ -194,8 +193,7 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var output = await BuildParcelCreateOutputAsync(order); - ApplyPickupPlan(input, output); - await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: true, operatorId); + await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); return output; } catch (BusinessException) @@ -2286,7 +2284,7 @@ public partial class OrderService } /// - /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货写入后置步骤,提交后执行。 + /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。 /// internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null) { @@ -2331,7 +2329,38 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); - await EnqueueDraftPredeclareSideEffectsAsync(order.Id, order.CouponInstanceId, operatorId); + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload + { + CouponInstanceId = order.CouponInstanceId + }); + await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); + } + else + { + var orderId = order.Id; + var couponInstanceId = order.CouponInstanceId; + await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); + } + + await ScheduleForecastSideEffectsAsync(order.Id, operatorId); + + // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 + var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); + if (refreshed != null + && string.Equals(refreshed.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(refreshed.PredeclareFailReason) + && !string.Equals( + refreshed.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal)) + { + throw CreateDraftSavedBusinessException( + refreshed, + StripDraftSavedMarker(refreshed.PredeclareFailReason)); + } } catch (BusinessException ex) { @@ -2358,11 +2387,10 @@ public partial class OrderService private async Task MarkParcelDraftFailedAsync(Order order, string reason) { - var text = TrimPendingReason(reason); order.OrderStatus = OrderStatusCodes.Draft; - order.PredeclareFailReason = text; + order.PredeclareFailReason = reason; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdatePredeclareFailureAsync(order.Id, text, order.IsRemote); + await OrderRepository.UpdateAsync(order); } /// @@ -2769,7 +2797,7 @@ public partial class OrderService } // 排货失败不阻断预报成功(与原创单同步路径一致) - await TryConfirmServiceChannelAndPayableAsync( + var allocated = await TryConfirmServiceChannelAndPayableAsync( order, product, billing, @@ -2795,23 +2823,16 @@ public partial class OrderService // ignore } + order.OrderStatus = OrderStatusCodes.Predeclared; order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdateForecastCompletionAsync(order); - var persisted = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); - if (persisted != null) - { - order.OrderStatus = persisted.OrderStatus; - order.PredeclareFailReason = persisted.PredeclareFailReason; - order.PreviousOrderStatus = persisted.PreviousOrderStatus; - order.LabelTemplateId = persisted.LabelTemplateId; - } - else - { - order.OrderStatus = OrderStatusCodes.Predeclared; - } + await OrderRepository.UpdateAsync(order); - await EnqueueSettlementSideEffectsAsync(order.Id, operatorUserId); + // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 + if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode)) + { + await ScheduleTrackAfterAllocationAsync(order.Id); + } var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); await OperationLogWriter.TryWriteAsync( @@ -2831,6 +2852,103 @@ public partial class OrderService public int? CouponInstanceId { 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; + + 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); + }); + 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) + { + 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))); + } + + /// + /// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。 + /// + 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) + { + 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))); + } + private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); @@ -2843,7 +2961,19 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); } - internal async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) + private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) + { + 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); + } + + private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2855,11 +2985,12 @@ public partial class OrderService await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } - private async Task PublishForecastTrackAsync(int orderId, bool appendInitialTrack, string? authorization) + private async Task PublishForecastTrackAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(order.ChannelCode)) { return; } @@ -2868,12 +2999,8 @@ public partial class OrderService ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); - request.AppendInitialTrackOnSuccess = appendInitialTrack; - if (!string.IsNullOrWhiteSpace(authorization)) - { - request.Authorization = authorization.Trim(); - } - + request.AppendInitialTrackOnSuccess = true; + ApplyTrackAuthorization(request); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } diff --git a/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs b/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs deleted file mode 100644 index 204230f..0000000 --- a/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs +++ /dev/null @@ -1,317 +0,0 @@ -using Fake.Application; -using Fake.UnitOfWork; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Oms.Application.Dtos.Orders; -using Oms.Application.Messaging; -using Oms.Domain.OrdersAggregate.Consts; -using Oms.Domain.OrdersAggregate.Entities; - -namespace Oms.Application.Services.Orders; - -public partial class OrderService -{ - private OrderSideEffectQueue SideEffectQueue => - LazyServiceProvider.GetRequiredService(); - - /// - /// 创单事务内写入后置步骤。保存草稿只写 9610 和揽收;确认下单再写预报、轨迹、面单和自动触发。 - /// - private async Task EnqueueCreateSideEffectsAsync( - int orderId, - OrderParcelCreateInput input, - bool includeForecast, - int operatorUserId) - { - var authorization = CaptureRequestAuthorization(); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.Customs9610, - new OrderSideEffectPayload { PlatformCode = input.PlatformCode }); - - if (includeForecast) - { - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.ForecastBilling, - new OrderSideEffectPayload - { - CouponInstanceId = input.CouponInstanceId, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.TrackUpsert, - new OrderSideEffectPayload - { - AppendInitialTrack = true, - Authorization = authorization, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.LabelGenerate, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.AutoTrigger, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - } - - if (IsDoorPickup(input) && ValidateDoorPickup(input) == null) - { - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.PickupAttach, - BuildPickupPayload(input)); - } - - await KickSideEffectsAsync(); - } - - /// - /// 草稿重新预报:预报计费可再次入队;轨迹 A0101、面单和创单触发已做过则跳过。 - /// - private async Task EnqueueDraftPredeclareSideEffectsAsync( - int orderId, - int? couponInstanceId, - int operatorUserId) - { - var authorization = CaptureRequestAuthorization(); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.ForecastBilling, - new OrderSideEffectPayload - { - CouponInstanceId = couponInstanceId, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.TrackUpsert, - new OrderSideEffectPayload - { - AppendInitialTrack = true, - Authorization = authorization, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.LabelGenerate, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.AutoTrigger, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await KickSideEffectsAsync(); - } - - /// - /// 预报计费成功后补轨迹渠道、系统面单,以及依赖计费结果的自动触发。 - /// - private async Task EnqueueSettlementSideEffectsAsync(int orderId, int operatorUserId) - { - var previousTrack = await SideEffectQueue.FindLatestPayloadAsync(orderId, OrderSideEffectSteps.TrackUpsert); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.TrackRefresh, - new OrderSideEffectPayload - { - AppendInitialTrack = false, - Authorization = previousTrack?.Authorization, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.LabelRefresh, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.AutoTriggerSettled, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await KickSideEffectsAsync(); - } - - private Task KickSideEffectsAsync() - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderSideEffect"); - return OrderSideEffectExecutor.ScheduleAfterCommitAsync(unitOfWorkManager, scopeFactory, logger); - } - - /// - /// 执行一条已抢占的后置步骤。由独立事务调用,失败抛出以便重试。 - /// - internal async Task ExecuteSideEffectAsync(OrderSideEffect row, CancellationToken cancellationToken = default) - { - var payload = string.IsNullOrWhiteSpace(row.PayloadJson) - ? new OrderSideEffectPayload() - : JsonConvert.DeserializeObject(row.PayloadJson) ?? new OrderSideEffectPayload(); - - switch (row.Step) - { - case OrderSideEffectSteps.Customs9610: - await PublishCustomsForOrderAsync(row.OrderId, payload.PlatformCode); - break; - case OrderSideEffectSteps.ForecastBilling: - await ExecuteForecastBillingStepAsync(row.OrderId, payload); - break; - case OrderSideEffectSteps.TrackUpsert: - await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: true, payload.Authorization); - break; - case OrderSideEffectSteps.TrackRefresh: - await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: false, payload.Authorization); - break; - case OrderSideEffectSteps.LabelGenerate: - case OrderSideEffectSteps.LabelRefresh: - await PublishForecastLabelAsync(row.OrderId, payload.OperatorUserId); - break; - case OrderSideEffectSteps.AutoTrigger: - await PublishForecastAutoTriggerAsync(row.OrderId, payload.OperatorUserId); - break; - case OrderSideEffectSteps.AutoTriggerSettled: - await PublishSettledAutoTriggerAsync(row.OrderId, payload.OperatorUserId); - break; - case OrderSideEffectSteps.PickupAttach: - await ExecutePickupSideEffectAsync(row.OrderId, payload); - break; - default: - LazyServiceProvider.GetRequiredService() - .CreateLogger("Oms.OrderSideEffect") - .LogWarning("未知的订单后置步骤 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); - break; - } - } - - private async Task ExecuteForecastBillingStepAsync(int orderId, OrderSideEffectPayload payload) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (!string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) - { - return; - } - - await EnqueueForecastBillingAsync(order, payload.CouponInstanceId, payload.OperatorUserId); - } - - private async Task PublishSettledAutoTriggerAsync(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.FireSettledByOrderIdAsync(orderId, operatorUserId); - } - - private async Task ExecutePickupSideEffectAsync(int orderId, OrderSideEffectPayload payload) - { - var input = new OrderParcelCreateInput - { - CustomerId = payload.CustomerId, - InboundMethod = payload.InboundMethod, - SenderAddressId = payload.SenderAddressId, - WarehouseId = payload.WarehouseId, - PickupDirection = payload.PickupDirection, - CarrierCode = payload.CarrierCode, - CarrierService = payload.CarrierService, - ExpectPickupTime = payload.ExpectPickupTime - }; - var output = new OrderParcelCreateOutput { OrderId = orderId }; - await TryAttachPickupAfterParcelCreateAsync(input, output); - if (string.IsNullOrWhiteSpace(output.PickupCreateError)) - { - return; - } - - if (output.PickupCreateError.Contains("已绑定", StringComparison.Ordinal) - || output.PickupCreateError.Contains("已在当前揽收单", StringComparison.Ordinal)) - { - return; - } - - throw new BusinessException(output.PickupCreateError); - } - - private void ApplyPickupPlan(OrderParcelCreateInput input, OrderParcelCreateOutput output) - { - if (!IsDoorPickup(input)) - { - return; - } - - var error = ValidateDoorPickup(input); - if (error != null) - { - output.PickupCreateError = error; - return; - } - - output.PickupQueued = true; - } - - private static bool IsDoorPickup(OrderParcelCreateInput input) => - string.Equals(input.InboundMethod?.Trim(), OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase); - - private static string? ValidateDoorPickup(OrderParcelCreateInput input) - { - if (input.SenderAddressId is not > 0) - { - return "上门揽收需选择揽收地址"; - } - - if (input.ExpectPickupTime is null || input.ExpectPickupTime == default) - { - return "上门揽收需填写期望揽收时间"; - } - - if (input.WarehouseId is not > 0) - { - return "上门揽收需选择仓库"; - } - - return null; - } - - private static OrderSideEffectPayload BuildPickupPayload(OrderParcelCreateInput input) => - new() - { - CustomerId = input.CustomerId, - SenderAddressId = input.SenderAddressId, - WarehouseId = input.WarehouseId, - PickupDirection = input.PickupDirection, - CarrierCode = input.CarrierCode, - CarrierService = input.CarrierService, - ExpectPickupTime = input.ExpectPickupTime, - InboundMethod = input.InboundMethod - }; - - private string? CaptureRequestAuthorization() - { - try - { - return LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); - } - catch (ObjectDisposedException) - { - return null; - } - } -} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index ff7c50b..2e5fe6d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -262,10 +262,10 @@ public partial class OrderService : ApplicationService } /// - /// 执行订单预报。校验通过后订单保持草稿并标记处理中,计费和排货在事务提交后执行。 + /// 执行订单预报。 /// /// 入参:订单操作参数。 - /// 无返回体。预报结果随后写回订单状态和失败原因。 + /// 无返回体,仅完成订单预报处理。 [HttpPost("/order/pre-declare")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task PreDeclareAsync(OrderActionInput input) @@ -603,10 +603,34 @@ public partial class OrderService : ApplicationService } /// - /// 创建订单包裹。 + /// 创建订单包裹(PC/H5 确认下单或保存草稿)。 + /// + /// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 → + /// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。 + /// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回; + /// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。 + /// + /// + /// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】 + /// 1) 9610 订单 Upsert(customs-9610)— 草稿与确认下单都会投; + /// 2) 预报计费/排货任务(forecast-billing 批量任务)— 仅确认下单; + /// 队列关闭时在后台同步跑同一套计费逻辑; + /// 3) 系统面单 PDF(label-generate)— 仅确认下单; + /// 4) 下单自动触发(OrderCreate:警告/问题件等,不含拦截)— 仅确认下单; + /// 5) 上门揽收挂载(pickup-attach)— 仅上门揽收且地址/时间/仓库齐全时; + /// 投递失败则同步回落建揽收单。 + /// + /// + /// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。 + /// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert); + /// 排货失败不阻断预报,也不建轨迹(可页面补做)。 + /// /// - /// 入参:订单包裹创建参数。 - /// 订单包裹创建结果。 + /// 入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。 + /// + /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队; + /// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。 + /// [HttpPost("/order/parcel/create")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task CreateParcelAsync(OrderParcelCreateInput input) diff --git a/src/Oms.Application/Workers/OrderSideEffectWorker.cs b/src/Oms.Application/Workers/OrderSideEffectWorker.cs deleted file mode 100644 index 9f28943..0000000 --- a/src/Oms.Application/Workers/OrderSideEffectWorker.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Oms.Application.Messaging; - -namespace Oms.Application.Workers; - -/// -/// 轮询已提交的创单后置步骤。进程在事务提交后、步骤执行前退出时,靠这里补跑。 -/// -public sealed class OrderSideEffectWorker( - IServiceScopeFactory scopeFactory, - ILogger logger) : BackgroundService -{ - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await OrderSideEffectExecutor.RecoverStuckForecastAsync(scopeFactory, logger, stoppingToken); - await OrderSideEffectExecutor.ProcessDueAsync(scopeFactory, logger, stoppingToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError(ex, "创单后置步骤轮询失败"); - } - - try - { - await Task.Delay(PollInterval, stoppingToken); - } - catch (OperationCanceledException) - { - break; - } - } - } -} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs deleted file mode 100644 index 9fc3d01..0000000 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Oms.Domain.OrdersAggregate.Consts; - -/// -/// 创单后置步骤状态。同一订单、同一步骤不建唯一索引,是否重复入队由业务判断。 -/// -public static class OrderSideEffectStatusCodes -{ - public const string Pending = "pending"; - - public const string Running = "running"; - - public const string Done = "done"; - - public const string Failed = "failed"; -} - -/// -/// 创单/预报提交后要单独执行的步骤。 -/// -public static class OrderSideEffectSteps -{ - public const string Customs9610 = "customs-9610"; - - public const string ForecastBilling = "forecast-billing"; - - /// 创建轨迹订单并追加 A0101,同一订单只做一次。 - public const string TrackUpsert = "track-upsert"; - - /// 排货完成后按最新渠道再同步一次轨迹,不追加 A0101。 - public const string TrackRefresh = "track-refresh"; - - public const string LabelGenerate = "label-generate"; - - /// 排货完成后重做系统面单,覆盖创单时还没有渠道的那张。 - public const string LabelRefresh = "label-refresh"; - - /// 下单时机的自动触发。此时还没有计费重、应收和渠道。 - public const string AutoTrigger = "auto-trigger"; - - /// 预报计费成功后再跑依赖计费结果的规则,以及草稿阶段没能打上的问题件。 - public const string AutoTriggerSettled = "auto-trigger-settled"; - - public const string PickupAttach = "pickup-attach"; -} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs deleted file mode 100644 index feb5471..0000000 --- a/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Oms.Domain.Shared; -using SqlSugar; - -namespace Oms.Domain.OrdersAggregate.Entities; - -/// -/// 与订单同一事务写入的后置步骤。事务回滚则步骤一起消失;提交后由后台执行,进程重启仍可继续。 -/// -[SugarTable("t_order_side_effect")] -public class OrderSideEffect : OmsAuditedAggregateRoot -{ - public int OrderId { get; set; } - - [SugarColumn(Length = 64)] - public string Step { get; set; } = string.Empty; - - [SugarColumn(Length = 32)] - public string Status { get; set; } = string.Empty; - - [SugarColumn(ColumnDataType = "text", IsNullable = true)] - public string? PayloadJson { get; set; } - - public int AttemptCount { get; set; } - - [SugarColumn(IsNullable = true)] - public DateTime? NextRunTime { get; set; } - - [SugarColumn(Length = 512, IsNullable = true)] - public string? LastError { get; set; } -} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index cf35dfd..c5d8ab0 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -137,23 +137,4 @@ public interface IOrderRepository : IRepository /// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。 /// Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default); - - /// - /// 预报失败时只写状态和原因,不覆盖渠道、面单模板、问题件字段。 - /// - Task UpdatePredeclareFailureAsync( - int orderId, - string reason, - byte? isRemote = null, - CancellationToken cancellationToken = default); - - /// - /// 预报计费成功时只写计费/排货字段。订单已是问题件或理赔件时不改状态,只把异常前状态改成已预报。 - /// - Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default); - - /// - /// 只更新拦截标记。 - /// - Task UpdateInterceptFlagAsync(int orderId, byte isIntercept, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs deleted file mode 100644 index 7d7e724..0000000 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Fake.Domain.Repositories; -using Oms.Domain.OrdersAggregate.Entities; - -namespace Oms.Domain.OrdersAggregate.Repositories; - -public interface IOrderSideEffectRepository : IRepository -{ - /// - /// 是否已有未结束(以及可选的已完成)步骤。用于避免重复入队。 - /// - Task ExistsAsync( - int orderId, - string step, - bool includeDone, - CancellationToken cancellationToken = default); - - Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default); - - /// - /// 取同一步骤最近一条入参,用于把创单时捕获的轨迹授权头带到排货后的刷新。 - /// - Task FindLatestPayloadJsonAsync( - int orderId, - string step, - CancellationToken cancellationToken = default); - - /// - /// 仅当当前仍是 pending 时改为 running,并增加尝试次数。 - /// - Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default); - - Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default); - - Task MarkRetryAsync( - int id, - string status, - string? lastError, - DateTime? nextRunTime, - DateTime now, - CancellationToken cancellationToken = default); - - /// - /// 执行过程中进程退出时,把长时间停在 running 的步骤放回 pending。 - /// - Task ResetStaleRunningAsync(DateTime staleBefore, DateTime now, CancellationToken cancellationToken = default); - - /// - /// 仍显示「预报计费/排货处理中」、且没有待执行、执行中或已完成预报步骤的订单。 - /// - Task> ListStuckForecastOrderIdsAsync( - string processingReason, - DateTime olderThan, - int take, - CancellationToken cancellationToken = default); -} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index f9ad549..c85d43f 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1505,85 +1505,4 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .Where(x => x.OrderStatus != OrderStatusCodes.Deleted) .ToListAsync(cancellationToken); } - - public async Task UpdatePredeclareFailureAsync( - int orderId, - string reason, - byte? isRemote = null, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var now = DateTime.Now; - var update = db.Updateable() - .SetColumns(x => x.OrderStatus == OrderStatusCodes.Draft) - .SetColumns(x => x.PredeclareFailReason == reason) - .SetColumns(x => x.UpdateTime == now); - if (isRemote.HasValue) - { - var remote = isRemote.Value; - update = update.SetColumns(x => x.IsRemote == remote); - } - - await update.Where(x => x.Id == orderId).ExecuteCommandAsync(cancellationToken); - } - - public async Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var now = DateTime.Now; - await db.Updateable() - .SetColumns(x => x.IsRemote == order.IsRemote) - .SetColumns(x => x.ChannelCode == order.ChannelCode) - .SetColumns(x => x.DownstreamSupplierCode == order.DownstreamSupplierCode) - .SetColumns(x => x.DownstreamSupplierName == order.DownstreamSupplierName) - .SetColumns(x => x.DownstreamAppKey == order.DownstreamAppKey) - .SetColumns(x => x.DownstreamAppId == order.DownstreamAppId) - .SetColumns(x => x.ChannelForecastCode == order.ChannelForecastCode) - .SetColumns(x => x.DownstreamServiceNo == order.DownstreamServiceNo) - .SetColumns(x => x.TrackNo == order.TrackNo) - .SetColumns(x => x.AllocationPendingReason == order.AllocationPendingReason) - .SetColumns(x => x.AllocationScheme == order.AllocationScheme) - .SetColumns(x => x.AllocationTraceJson == order.AllocationTraceJson) - .SetColumns(x => x.AllocationTraceTime == order.AllocationTraceTime) - .SetColumns(x => x.PredeclareFailReason == string.Empty) - .SetColumns(x => x.MemberLevelId == order.MemberLevelId) - .SetColumns(x => x.PriceTierKey == order.PriceTierKey) - .SetColumns(x => x.ReceivableSubtotal == order.ReceivableSubtotal) - .SetColumns(x => x.LevelDiscountAmount == order.LevelDiscountAmount) - .SetColumns(x => x.CouponInstanceId == order.CouponInstanceId) - .SetColumns(x => x.CouponDiscountAmount == order.CouponDiscountAmount) - .SetColumns(x => x.PayableAmount == order.PayableAmount) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == order.Id) - .ExecuteCommandAsync(cancellationToken); - - await db.Updateable() - .SetColumns(x => x.OrderStatus == OrderStatusCodes.Predeclared) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == order.Id - && x.OrderStatus != OrderStatusCodes.Problematic - && x.OrderStatus != OrderStatusCodes.Claimed) - .ExecuteCommandAsync(cancellationToken); - - await db.Updateable() - .SetColumns(x => x.PreviousOrderStatus == OrderStatusCodes.Predeclared) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == order.Id - && (x.OrderStatus == OrderStatusCodes.Problematic - || x.OrderStatus == OrderStatusCodes.Claimed)) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task UpdateInterceptFlagAsync( - int orderId, - byte isIntercept, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - await db.Updateable() - .SetColumns(x => x.IsIntercept == isIntercept) - .SetColumns(x => x.UpdateTime == DateTime.Now) - .Where(x => x.Id == orderId) - .ExecuteCommandAsync(cancellationToken); - } } diff --git a/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs deleted file mode 100644 index 4d6290a..0000000 --- a/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Fake.DependencyInjection; -using Oms.Domain.OrdersAggregate.Consts; -using SqlSugar; -using Oms.Domain.OrdersAggregate.Entities; -using Oms.Domain.OrdersAggregate.Repositories; -using Oms.SqlSugarCore.Repositories; - -namespace Oms.SqlSugarCore.Orders; - -public class OrderSideEffectRepository : OmsSqlSugarRepository, IOrderSideEffectRepository, IScopedDependency -{ - public async Task ExistsAsync( - int orderId, - string step, - bool includeDone, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var statuses = includeDone - ? new[] - { - OrderSideEffectStatusCodes.Pending, - OrderSideEffectStatusCodes.Running, - OrderSideEffectStatusCodes.Done - } - : new[] - { - OrderSideEffectStatusCodes.Pending, - OrderSideEffectStatusCodes.Running - }; - - return await db.Queryable() - .AnyAsync( - x => x.OrderId == orderId - && x.Step == step - && statuses.Contains(x.Status) - && x.IsDeleted == false, - cancellationToken); - } - - public async Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - return await db.Queryable() - .Where(x => x.Status == OrderSideEffectStatusCodes.Pending - && x.IsDeleted == false - && (x.NextRunTime == null || x.NextRunTime <= now)) - .OrderBy(x => x.Id) - .Take(take) - .Select(x => x.Id) - .ToListAsync(cancellationToken); - } - - public async Task FindLatestPayloadJsonAsync( - int orderId, - string step, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var rows = await db.Queryable() - .Where(x => x.OrderId == orderId && x.Step == step && x.IsDeleted == false) - .OrderByDescending(x => x.Id) - .Take(1) - .Select(x => x.PayloadJson) - .ToListAsync(cancellationToken); - return rows.FirstOrDefault(); - } - - public async Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var affected = await db.Updateable() - .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Running) - .SetColumns(x => x.UpdateTime == now) - .SetColumns(x => x.AttemptCount == x.AttemptCount + 1) - .Where(x => x.Id == id - && x.Status == OrderSideEffectStatusCodes.Pending - && x.IsDeleted == false) - .ExecuteCommandAsync(cancellationToken); - return affected == 1; - } - - public async Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - await db.Updateable() - .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Done) - .SetColumns(x => x.UpdateTime == now) - .SetColumns(x => x.LastError == null) - .Where(x => x.Id == id) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task MarkRetryAsync( - int id, - string status, - string? lastError, - DateTime? nextRunTime, - DateTime now, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - await db.Updateable() - .SetColumns(x => x.Status == status) - .SetColumns(x => x.LastError == lastError) - .SetColumns(x => x.NextRunTime == nextRunTime) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == id) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task ResetStaleRunningAsync( - DateTime staleBefore, - DateTime now, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - return await db.Updateable() - .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Pending) - .SetColumns(x => x.NextRunTime == now) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Status == OrderSideEffectStatusCodes.Running - && x.IsDeleted == false - && x.UpdateTime < staleBefore) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task> ListStuckForecastOrderIdsAsync( - string processingReason, - DateTime olderThan, - int take, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - return await db.Queryable() - .Where(order => order.IsDeleted == false - && order.PredeclareFailReason == processingReason - && order.UpdateTime < olderThan - && order.UpdateTime > DateTime.MinValue) - .Where(order => !SqlSugar.SqlFunc.Subqueryable() - .Where(effect => effect.OrderId == order.Id - && effect.Step == OrderSideEffectSteps.ForecastBilling - && effect.IsDeleted == false - && (effect.Status == OrderSideEffectStatusCodes.Pending - || effect.Status == OrderSideEffectStatusCodes.Running - || effect.Status == OrderSideEffectStatusCodes.Done)) - .Any()) - .OrderBy(order => order.Id) - .Take(take) - .Select(order => order.Id) - .ToListAsync(cancellationToken); - } -} From a30958843bc005231cc1bbe68d56ee569bb1c936 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 09:14:52 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E9=A6=96=E6=AC=A1=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E8=AE=A2=E5=8D=95=E6=97=A0=E8=B7=9F=E8=B8=AA?= =?UTF-8?q?=E5=8F=B7=E6=97=B6=E7=94=A8=E5=AE=A2=E6=88=B7=E5=8D=95=E5=8F=B7?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 轨迹侧 TrackNo 必填,后续有真实跟踪号再 Upsert 更新。 Co-authored-by: Cursor --- .../SmallBagTrackUpsertRequestBuilder.cs | 14 +++++++++++--- .../SmallBagTrackUpsertRequestBuilderTests.cs | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs index 19eef09..53a5d17 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs @@ -68,10 +68,18 @@ internal sealed class SmallBagTrackUpsertRequestBuilder( } /// - /// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。 + /// 轨迹侧 TrackNo 必填。有真实跟踪号用跟踪号;尚未下发时先用客户单号占位,后续有 TrackNo 再 Upsert 更新。 /// - internal static string ResolveTrackNo(Order order) => - order.TrackNo?.Trim() ?? string.Empty; + internal static string ResolveTrackNo(Order order) + { + var trackNo = order.TrackNo?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(trackNo)) + { + return trackNo; + } + + return order.ClientNo?.Trim() ?? string.Empty; + } internal static string? PickSupplierCodeFromProviders( IEnumerable providers, diff --git a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs index df70e79..47147c0 100644 --- a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs +++ b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs @@ -20,14 +20,28 @@ public class SmallBagTrackUpsertRequestBuilderTests } [Fact] - public void ResolveTrackNo_EmptyWhenTrackNoMissing() + public void ResolveTrackNo_FallsBackToClientNoWhenTrackNoMissing() { var order = new Order { ServiceNo = "IDE2607221859123456", + ClientNo = "CNO-001", TrackNo = null }; + Assert.Equal("CNO-001", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); + } + + [Fact] + public void ResolveTrackNo_EmptyWhenTrackNoAndClientNoMissing() + { + var order = new Order + { + ServiceNo = "IDE2607221859123456", + TrackNo = null, + ClientNo = null + }; + Assert.Equal(string.Empty, SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); } From aa4c4a28e637a086141e4a033123e70b954890f0 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 09:23:15 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E6=8E=92=E8=B4=A7=E5=90=8E=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E8=BD=A8=E8=BF=B9=E8=AE=A2=E5=8D=95=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E5=88=9B=E5=8D=95=E6=97=B6=E7=9A=84=20Authorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消费端没有登录态会导致轨迹网关 401;创单时写入预报任务再带到轨迹事件。 Co-authored-by: Cursor --- .../Batch/OrderBatchOrderActionHandler.cs | 3 +- .../Orders/OrderService.ParcelBilling.cs | 116 ++++++++++++++---- .../Services/Orders/OrderService.cs | 1 + 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs index f363cd8..664bb91 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs @@ -72,7 +72,8 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto message.OrderId, message.PayloadJson, message.OperatorUserId, - cancellationToken); + cancellationToken, + message.Authorization); return OrderBatchActionExecuteResult.Ok(); case OrderBatchActionCodes.Reallocate: diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index f1f1a5f..56ef739 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2329,20 +2329,26 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + var authorization = CaptureRequestAuthorization(); var publisher = LazyServiceProvider.GetRequiredService(); if (!publisher.IsEnabled) { var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload { - CouponInstanceId = order.CouponInstanceId + CouponInstanceId = order.CouponInstanceId, + Authorization = authorization }); - await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); + await ProcessForecastBillingForBatchAsync( + order.Id, + payloadJson, + operatorId, + authorization: authorization); } else { var orderId = order.Id; var couponInstanceId = order.CouponInstanceId; - await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); + await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId, authorization); } await ScheduleForecastSideEffectsAsync(order.Id, operatorId); @@ -2663,7 +2669,8 @@ public partial class OrderService int orderId, string? payloadJson = null, int operatorUserId = 0, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? authorization = null) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null) @@ -2687,6 +2694,7 @@ public partial class OrderService var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); int? couponInstanceId = order.CouponInstanceId; + string? trackAuthorization = authorization; if (!string.IsNullOrWhiteSpace(payloadJson)) { try @@ -2696,6 +2704,12 @@ public partial class OrderService { couponInstanceId = payload.CouponInstanceId; } + + if (string.IsNullOrWhiteSpace(trackAuthorization) + && !string.IsNullOrWhiteSpace(payload?.Authorization)) + { + trackAuthorization = payload.Authorization.Trim(); + } } catch { @@ -2829,9 +2843,10 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 + // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode)) { - await ScheduleTrackAfterAllocationAsync(order.Id); + await ScheduleTrackAfterAllocationAsync(order.Id, trackAuthorization); } var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); @@ -2850,6 +2865,11 @@ public partial class OrderService private sealed class ForecastBillingJobPayload { public int? CouponInstanceId { get; set; } + + /// + /// 创单/预报请求的 Authorization,供排货后异步创建轨迹订单转发到轨迹网关。 + /// + public string? Authorization { get; set; } } /// @@ -2868,6 +2888,8 @@ public partial class OrderService var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate"); var platformCode = input.PlatformCode; var couponInstanceId = input.CouponInstanceId; + // 事务提交后 HttpContext 已不可用,这里先捕获登录态供轨迹网关使用。 + var authorization = CaptureRequestAuthorization(); await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => { @@ -2882,7 +2904,11 @@ public partial class OrderService forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => { var service = sp.GetRequiredService(); - await service.EnqueueForecastBillingByIdAsync(orderId, couponInstanceId, operatorUserId); + await service.EnqueueForecastBillingByIdAsync( + orderId, + couponInstanceId, + operatorUserId, + authorization); }); await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); @@ -2906,7 +2932,11 @@ public partial class OrderService }); } - private async Task ScheduleForecastEnqueueAsync(int orderId, int? couponInstanceId, int operatorUserId) + private async Task ScheduleForecastEnqueueAsync( + int orderId, + int? couponInstanceId, + int operatorUserId, + string? authorization) { var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); var scopeFactory = LazyServiceProvider.GetRequiredService(); @@ -2916,7 +2946,8 @@ public partial class OrderService sp.GetRequiredService().EnqueueForecastBillingByIdAsync( orderId, couponInstanceId, - operatorUserId))); + operatorUserId, + authorization))); } /// @@ -2939,14 +2970,14 @@ public partial class OrderService /// /// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。 /// - private async Task ScheduleTrackAfterAllocationAsync(int orderId) + 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))); + sp.GetRequiredService().PublishForecastTrackAsync(orderId, authorization))); } private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) @@ -2961,7 +2992,11 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); } - private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) + private async Task EnqueueForecastBillingByIdAsync( + int orderId, + int? couponInstanceId, + int operatorUserId, + string? authorization) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2970,7 +3005,7 @@ public partial class OrderService return; } - await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId); + await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId, authorization); } private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) @@ -2985,7 +3020,7 @@ public partial class OrderService await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } - private async Task PublishForecastTrackAsync(int orderId) + private async Task PublishForecastTrackAsync(int orderId, string? authorization = null) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -3000,7 +3035,7 @@ public partial class OrderService : null; var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); request.AppendInitialTrackOnSuccess = true; - ApplyTrackAuthorization(request); + ApplyTrackAuthorization(request, authorization); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } @@ -3031,13 +3066,16 @@ public partial class OrderService private async Task EnqueueForecastBillingAsync( Order order, int? couponInstanceId, - int operatorUserId) + int operatorUserId, + string? authorization = null) { var logger = LazyServiceProvider.GetRequiredService() .CreateLogger("Oms.ForecastBilling"); + authorization ??= CaptureRequestAuthorization(); var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload { - CouponInstanceId = couponInstanceId + CouponInstanceId = couponInstanceId, + Authorization = authorization }); try @@ -3047,7 +3085,11 @@ public partial class OrderService { try { - await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorUserId); + await ProcessForecastBillingForBatchAsync( + order.Id, + payloadJson, + operatorUserId, + authorization: authorization); } catch (Exception ex) { @@ -3073,7 +3115,8 @@ public partial class OrderService RequestId = requestId, PayloadJson = payloadJson }, - operatorUserId); + operatorUserId, + authorization); } catch (Exception ex) { @@ -3375,18 +3418,37 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } - private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request) + private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null) { - var authorization = LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); if (!string.IsNullOrWhiteSpace(authorization)) { - request.Authorization = authorization; + request.Authorization = authorization.Trim(); + return; + } + + var fromRequest = CaptureRequestAuthorization(); + if (!string.IsNullOrWhiteSpace(fromRequest)) + { + request.Authorization = fromRequest; + } + } + + private string? CaptureRequestAuthorization() + { + try + { + var authorization = LazyServiceProvider + .GetRequiredService() + .HttpContext? + .Request + .Headers + .Authorization + .ToString(); + return string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim(); + } + catch (ObjectDisposedException) + { + return null; } } diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 2e5fe6d..1932297 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -623,6 +623,7 @@ public partial class OrderService : ApplicationService /// /// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。 /// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert); + /// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发; /// 排货失败不阻断预报,也不建轨迹(可页面补做)。 /// /// From 8258ae746ee408bd73d01ffafd05e8c79cb92527 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 19:53:24 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E5=AE=A2=E6=88=B7=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=E5=B1=80=E9=83=A8?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=EF=BC=8C=E5=B9=B6=E8=A1=A5=E5=85=85=E5=BC=80?= =?UTF-8?q?=E5=8F=91/SQL=20=E8=A7=84=E8=8C=83=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- README.md | 55 ++++++++++++ sql/README.md | 10 +++ .../Dtos/Customers/UpdateCustomerInput.cs | 37 ++++---- .../Services/Customers/CustomerService.cs | 86 +++++++++++++++---- 4 files changed, 152 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 8af4aa8..a729463 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,61 @@ SimpleAdmin.Api/ └── README.md ``` +## 开发规范(强制) + +更完整的分层 / 路由 / DTO 约定见 `开发复刻说明.md`、`design.md`。下列为新增代码与合入共享分支的硬性标准,评审与上传前必须满足。 + +### 1. 写接口必须写注释 + +对外暴露的 Application API(`ApplicationService` 上的 `[HttpGet]` / `[HttpPost]` 等方法)**必须**有 XML / 中文注释,至少说明: + +- 接口用途(做什么) +- 关键入参含义与约束(可写在方法注释或 DTO 属性注释上) +- 关键返回语义或业务失败场景(如抛 `BusinessException` 的条件) + +DTO、领域常量、非显而易见的私有辅助方法也应补注释,避免「只有路由名能猜业务」。 + +```csharp +/// +/// 批量创建仓储代发订单:建单并绑定库存/包裹,产品须为分组 wms。 +/// +/// 仓库、产品编码与店铺订单绑定明细。 +[HttpPost("/order/platform-store-order/batch-create-fulfill-wms")] +public async Task BatchCreateFulfillWmsAsync( + PlatformStoreOrderBatchCreateFulfillInput input) +``` + +### 2. 写 SQL 必须走 ORM(SqlSugar) + +业务读写、列表筛选、联表、聚合、更新**一律通过 SqlSugar / Repository**,禁止在 Application / Domain 服务里手写拼装 SQL 字符串或 `Ado.SqlQuery` 作为常规实现。 + +| 允许 | 禁止(业务代码) | +|------|------------------| +| `Repository` / `ISugarQueryable` / 表达式 `Where` | 服务内大段 `"SELECT ... " +` 拼接 | +| 实体 + 仓储封装复杂查询 | 绕过仓储直接 ADO 执行业务 SQL | +| `sql/*.sql` 仅作建库 / 存量 DDL 迁移 | 用脚本替代日常业务 CRUD | + +存量 DDL、字典初始化等脚本放在 `sql/`(说明见 `sql/README.md`),与运行时 ORM 查询分离;**运行时查询与写入必须 ORM**。 + +### 3. 上传代码前先同步远端 + +`test`、`master` 等共享分支会有其他人提交。**推送前必须先拉取远端,再上传**,避免覆盖或漏掉别人的提交。 + +```bash +git fetch origin +git pull --ff-only origin <当前分支> +git push origin <当前分支> +``` + +- 工作区有未提交改动时,先暂存(stash)或提交,再同步,同步完成后再恢复。 +- 本地已有未推送提交、远端也有新提交时,先 rebase 或 merge 并解决冲突,确认与远端对齐后再 `push`。 +- 禁止在未同步远端的情况下直接 `push` 共享分支。 + +### 4. 相关约定速查 + +- **禁止**新建库级唯一索引(`UNIQUE KEY`):见 `.cursor/rules/no-unique-indexes.mdc`,业务唯一性在 Application 校验。 +- 路由显式声明;分页统一 `totalCount` + `items`;勿把 Entity 直接返回前端。 + ## Fake 框架特性 ### 自动 API 生成 diff --git a/sql/README.md b/sql/README.md index 9d84b35..b691aa6 100644 --- a/sql/README.md +++ b/sql/README.md @@ -2,6 +2,16 @@ OMS / 共用库的建表脚本已统一到仓库根目录 `db.sql`。 +## 职责边界 + +本目录**只放 DDL / 存量迁移 / 字典种子**等库结构与一次性数据脚本。 + +- **运行时业务查询与写入必须走 SqlSugar ORM(仓储)**,不要在 Application 里用手写 SQL 替代 CRUD。 +- 完整编码标准见仓库根目录 `README.md` →「开发规范」。 + +## 使用说明 + - 新环境:直接执行 `db.sql`(会 DROP 后重建,勿在有生产数据的库上执行) - 已有库:用 diff 对比 `db.sql` 与现库结构后手工 ALTER;本目录 `migrate_all.sql` 为存量增量迁移合集(可重复执行段已做列/表存在判断;揽收 Direction / FeePush 为裸 ALTER,已执行过请跳过) - 数据权限表(`user_org_perm` 等)若 RBAC 与 OMS 分库,请只在 RBAC 库执行 `db.sql` 末尾对应段落 +- 单次增量迁移命名:`migrate_<主题>.sql`,尽量可重复执行(先判断列/表/字典是否已存在) diff --git a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs index df706b0..2bd683d 100644 --- a/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/UpdateCustomerInput.cs @@ -15,67 +15,64 @@ public class UpdateCustomerInput public int Id { get; set; } /// - /// 客户编号。 + /// 客户编号;不传或空则保留原值(编辑页编号只读,允许局部更新省略)。 /// - [Required] [StringLength(32)] - public string CustomerCode { get; set; } = string.Empty; + public string? CustomerCode { get; set; } /// - /// 客户名称。 + /// 客户名称;不传或空则保留原值。 /// - [Required] [StringLength(128)] - public string CustomerName { get; set; } = string.Empty; + public string? CustomerName { get; set; } /// - /// 手机号。 + /// 手机号;不传或空则保留原值。 /// - [Required] [StringLength(16)] - public string Phone { get; set; } = string.Empty; + public string? Phone { get; set; } /// - /// 邮箱。 + /// 邮箱;不传则保留原值,传空串可清空。 /// [EmailAddress] [StringLength(64)] public string? Email { get; set; } /// - /// 微信头像地址。 + /// 微信头像地址;不传则保留原值,传空串可清空。 /// [StringLength(255)] public string? WechatAvatar { get; set; } /// - /// 微信昵称。 + /// 微信昵称;不传则保留原值,传空串可清空。 /// [StringLength(64)] public string? WechatNickname { get; set; } /// - /// 账户余额。 + /// 账户余额;不传则保留原值。 /// [Range(typeof(decimal), "0", "999999999999")] - public decimal Balance { get; set; } + public decimal? Balance { get; set; } /// - /// 总额度。 + /// 总额度;不传则保留原值。 /// [Range(typeof(decimal), "0", "999999999999")] - public decimal TotalQuota { get; set; } + public decimal? TotalQuota { get; set; } /// - /// 已用额度。 + /// 已用额度;不传则保留原值。 /// [Range(typeof(decimal), "0", "999999999999")] - public decimal UsedQuota { get; set; } + public decimal? UsedQuota { get; set; } /// - /// 状态。 + /// 状态;不传则保留原值。 /// - public CustomerStatus Status { get; set; } = CustomerStatus.Normal; + public CustomerStatus? Status { get; set; } /// /// 备注。 diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index 32cd9e8..02d8ab0 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -329,10 +329,17 @@ public class CustomerService : ApplicationService public async Task UpdateAsync(UpdateCustomerInput input) { var entity = await GetCustomerOrThrowAsync(input.Id); - var customerCode = NormalizeRequiredText(input.CustomerCode, "客户编号"); - var customerName = NormalizeRequiredText(input.CustomerName, "客户名称"); - var phone = NormalizeRequiredText(input.Phone, "手机号"); - var email = NormalizeOptionalText(input.Email); + // 未传字段保留原值,避免局部更新(如只改名称)把编号/余额/状态冲成空或默认值 + var customerCode = string.IsNullOrWhiteSpace(input.CustomerCode) + ? entity.CustomerCode + : NormalizeRequiredText(input.CustomerCode, "客户编号"); + var customerName = string.IsNullOrWhiteSpace(input.CustomerName) + ? entity.CustomerName + : NormalizeRequiredText(input.CustomerName, "客户名称"); + var phone = string.IsNullOrWhiteSpace(input.Phone) + ? entity.Phone + : NormalizeRequiredText(input.Phone, "手机号"); + var email = input.Email != null ? NormalizeOptionalText(input.Email) : entity.Email; await EnsureCustomerIdentityUniqueForUpdateAsync(entity.Id, customerCode, phone, email); @@ -340,18 +347,65 @@ public class CustomerService : ApplicationService entity.CustomerName = customerName; entity.Phone = phone; entity.Email = email; - entity.WechatAvatar = NormalizeOptionalText(input.WechatAvatar); - entity.WechatNickname = NormalizeOptionalText(input.WechatNickname); - entity.Balance = input.Balance; - entity.TotalQuota = input.TotalQuota; - entity.UsedQuota = input.UsedQuota; - entity.Status = input.Status; - entity.Remark = NormalizeOptionalText(input.Remark); - entity.Salesman = NormalizeOptionalText(input.Salesman); - entity.CustomerService = NormalizeOptionalText(input.CustomerService); - entity.CustomerGroup = input.CustomerGroup; - entity.MemberLevelId = await ResolveMemberLevelIdForSaveAsync(input.MemberLevelId); - entity.PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(input.PriceTierKey); + if (input.WechatAvatar != null) + { + entity.WechatAvatar = NormalizeOptionalText(input.WechatAvatar); + } + + if (input.WechatNickname != null) + { + entity.WechatNickname = NormalizeOptionalText(input.WechatNickname); + } + + if (input.Balance.HasValue) + { + entity.Balance = input.Balance.Value; + } + + if (input.TotalQuota.HasValue) + { + entity.TotalQuota = input.TotalQuota.Value; + } + + if (input.UsedQuota.HasValue) + { + entity.UsedQuota = input.UsedQuota.Value; + } + + if (input.Status.HasValue && input.Status.Value != CustomerStatus.None) + { + entity.Status = input.Status.Value; + } + + if (input.Remark != null) + { + entity.Remark = NormalizeOptionalText(input.Remark); + } + + if (input.Salesman != null) + { + entity.Salesman = NormalizeOptionalText(input.Salesman); + } + + if (input.CustomerService != null) + { + entity.CustomerService = NormalizeOptionalText(input.CustomerService); + } + + if (input.CustomerGroup.HasValue) + { + entity.CustomerGroup = input.CustomerGroup; + } + + if (input.MemberLevelId.HasValue) + { + entity.MemberLevelId = await ResolveMemberLevelIdForSaveAsync(input.MemberLevelId); + } + + if (input.PriceTierKey != null) + { + entity.PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(input.PriceTierKey); + } await CustomerRepository.UpdateAsync(entity); } From 66d383160f1ded0928fb646cd84633e8ec0bb9b6 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 20:11:46 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E5=BA=94=E6=94=B6=E5=BA=94=E4=BB=98?= =?UTF-8?q?=E3=80=8C=E4=BB=85=E5=BD=93=E5=89=8D=E7=94=9F=E6=95=88=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E3=80=8D=E5=88=97=E8=A1=A8=E9=87=91=E9=A2=9D=E4=B8=BA?= =?UTF-8?q?=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlSugar 在同一查询上 Select 后再分页会沿用投影列,导致 Amount/Currency 丢失;候选筛选与最终全字段查询拆开。 Co-authored-by: Cursor --- src/Oms.SqlSugarCore/Orders/OrderRepository.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index c85d43f..ad4e985 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1215,12 +1215,14 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I return feeQuery; } + // SqlSugar:在同一 ISugarQueryable 上 Select 后再 Where/ToPageList,会沿用投影列, + // 导致 Amount/Currency 等未选字段落成默认值(金额全 0、币种空)。候选查询与最终查询拆开。 var candidates = await feeQuery .Select(f => new { f.Id, f.OrderId, f.Type, f.CostItem, f.ChargingStage }) .ToListAsync(cancellationToken); if (candidates.Count == 0) { - return feeQuery.Where(_ => false); + return db.Queryable().Where(_ => false); } var winningIds = candidates @@ -1232,7 +1234,9 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .Id) .ToArray(); - return feeQuery.Where(f => winningIds.Contains(f.Id)); + return db.Queryable() + .Where(fee => !fee.IsDeleted) + .Where(f => winningIds.Contains(f.Id)); } private static int GetChargingStageRank(string? stage) =>