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);
+ }
+}