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/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/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/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.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..9b31ee7
--- /dev/null
+++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Fake.UnitOfWork;
+
+namespace Oms.Application.Messaging;
+
+///
+/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。
+///
+internal static class OmsAfterCommit
+{
+ public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action)
+ {
+ var current = unitOfWorkManager.Current;
+ if (current != null && !current.IsCompleted)
+ {
+ // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。
+ // 等工作单元释放后再开独立事务。
+ EventHandler handler = null!;
+ handler = (_, _) =>
+ {
+ current.Disposed -= handler;
+ _ = Task.Run(action);
+ };
+ current.Disposed += handler;
+ return Task.CompletedTask;
+ }
+
+ return action();
+ }
+
+ ///
+ /// 新开独立事务执行一步。失败只记日志,不抛回调用方。
+ ///
+ public static async Task RunStepAsync(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger,
+ string step,
+ Func work)
+ {
+ try
+ {
+ using var scope = scopeFactory.CreateScope();
+ var unitOfWorkManager = scope.ServiceProvider.GetRequiredService();
+ using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent());
+ await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager);
+ await work(scope.ServiceProvider);
+ await unitOfWork.CompleteAsync();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "订单后置步骤失败 Step={Step}", step);
+ return false;
+ }
+ }
+}
diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs
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/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/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);
}
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/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 1085937..56ef739 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,30 @@ 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 authorization = CaptureRequestAuthorization();
+ var publisher = LazyServiceProvider.GetRequiredService();
+ if (!publisher.IsEnabled)
+ {
+ var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
+ {
+ CouponInstanceId = order.CouponInstanceId,
+ Authorization = authorization
+ });
+ await ProcessForecastBillingForBatchAsync(
+ order.Id,
+ payloadJson,
+ operatorId,
+ authorization: authorization);
+ }
+ else
+ {
+ var orderId = order.Id;
+ var couponInstanceId = order.CouponInstanceId;
+ await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId, authorization);
+ }
+
+ await ScheduleForecastSideEffectsAsync(order.Id, operatorId);
// 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。
var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id);
@@ -2676,15 +2669,16 @@ 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)
{
- return;
+ // 创单事务尚未提交时消息可能先到。抛出让总线重试,避免当成成功 Ack 后订单永远停在处理中。
+ throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}");
}
- // 创单失败回滚 / 软删后,可能仍有短事务提前投递的孤儿任务
if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
{
return;
@@ -2700,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
@@ -2709,6 +2704,12 @@ public partial class OrderService
{
couponInstanceId = payload.CouponInstanceId;
}
+
+ if (string.IsNullOrWhiteSpace(trackAuthorization)
+ && !string.IsNullOrWhiteSpace(payload?.Authorization))
+ {
+ trackAuthorization = payload.Authorization.Trim();
+ }
}
catch
{
@@ -2810,7 +2811,7 @@ public partial class OrderService
}
// 排货失败不阻断预报成功(与原创单同步路径一致)
- await TryConfirmServiceChannelAndPayableAsync(
+ var allocated = await TryConfirmServiceChannelAndPayableAsync(
order,
product,
billing,
@@ -2841,6 +2842,13 @@ public partial class OrderService
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
+ // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。
+ // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。
+ if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode))
+ {
+ await ScheduleTrackAfterAllocationAsync(order.Id, trackAuthorization);
+ }
+
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
await OperationLogWriter.TryWriteAsync(
order,
@@ -2852,38 +2860,222 @@ 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
{
public int? CouponInstanceId { get; set; }
+
+ ///
+ /// 创单/预报请求的 Authorization,供排货后异步创建轨迹订单转发到轨迹网关。
+ ///
+ public string? Authorization { get; set; }
+ }
+
+ ///
+ /// 创单事务提交后,9610、预报计费、揽收、面单、自动触发各自提交。
+ /// 轨迹订单等排货成功后再创建。
+ ///
+ private async Task ScheduleCreateSideEffectsAsync(
+ int orderId,
+ OrderParcelCreateInput input,
+ OrderParcelCreateOutput output,
+ bool includeForecast,
+ int operatorUserId)
+ {
+ var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
+ var scopeFactory = LazyServiceProvider.GetRequiredService();
+ var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate");
+ var platformCode = input.PlatformCode;
+ var couponInstanceId = input.CouponInstanceId;
+ // 事务提交后 HttpContext 已不可用,这里先捕获登录态供轨迹网关使用。
+ var authorization = CaptureRequestAuthorization();
+
+ await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () =>
+ {
+ try
+ {
+ await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp =>
+ sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode));
+
+ var forecastQueued = true;
+ if (includeForecast)
+ {
+ forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp =>
+ {
+ var service = sp.GetRequiredService();
+ await service.EnqueueForecastBillingByIdAsync(
+ orderId,
+ couponInstanceId,
+ operatorUserId,
+ authorization);
+ });
+ await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
+ sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId));
+ await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
+ sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
+ }
+
+ await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp =>
+ sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output));
+
+ if (includeForecast && !forecastQueued)
+ {
+ await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp =>
+ sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId));
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId);
+ }
+ });
+ }
+
+ private async Task ScheduleForecastEnqueueAsync(
+ int orderId,
+ int? couponInstanceId,
+ int operatorUserId,
+ string? authorization)
+ {
+ var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
+ var scopeFactory = LazyServiceProvider.GetRequiredService();
+ var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling");
+ await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
+ OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp =>
+ sp.GetRequiredService().EnqueueForecastBillingByIdAsync(
+ orderId,
+ couponInstanceId,
+ operatorUserId,
+ authorization)));
+ }
+
+ ///
+ /// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。
+ ///
+ private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId)
+ {
+ var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
+ var scopeFactory = LazyServiceProvider.GetRequiredService();
+ var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling");
+ await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () =>
+ {
+ await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp =>
+ sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId));
+ await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp =>
+ sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId));
+ });
+ }
+
+ ///
+ /// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。
+ ///
+ private async Task ScheduleTrackAfterAllocationAsync(int orderId, string? authorization)
+ {
+ var unitOfWorkManager = LazyServiceProvider.GetRequiredService();
+ var scopeFactory = LazyServiceProvider.GetRequiredService();
+ var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling");
+ await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () =>
+ OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp =>
+ sp.GetRequiredService().PublishForecastTrackAsync(orderId, authorization)));
+ }
+
+ private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
+ if (order == null
+ || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode);
+ }
+
+ private async Task EnqueueForecastBillingByIdAsync(
+ int orderId,
+ int? couponInstanceId,
+ int operatorUserId,
+ string? authorization)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
+ if (order == null
+ || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId, authorization);
+ }
+
+ private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
+ if (order == null
+ || !string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason);
+ }
+
+ private async Task PublishForecastTrackAsync(int orderId, string? authorization = null)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
+ if (order == null
+ || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)
+ || string.IsNullOrWhiteSpace(order.ChannelCode))
+ {
+ 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, authorization);
+ await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
+ }
+
+ private async Task PublishForecastLabelAsync(int orderId, int operatorUserId)
+ {
+ var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
+ if (order == null
+ || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
+ {
+ 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,
- 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
@@ -2893,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)
{
@@ -2919,7 +3115,8 @@ public partial class OrderService
RequestId = requestId,
PayloadJson = payloadJson
},
- operatorUserId);
+ operatorUserId,
+ authorization);
}
catch (Exception ex)
{
@@ -3206,40 +3403,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,19 +3414,42 @@ public partial class OrderService
{
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
request.AppendInitialTrackOnSuccess = appendInitialTrack;
- var authorization = LazyServiceProvider
- .GetRequiredService()
- .HttpContext?
- .Request
- .Headers
- .Authorization
- .ToString();
+ ApplyTrackAuthorization(request);
+ await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
+ }
+
+ private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null)
+ {
if (!string.IsNullOrWhiteSpace(authorization))
{
- request.Authorization = authorization;
+ request.Authorization = authorization.Trim();
+ return;
}
- await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id);
+ 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 eaccbe2..1932297 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -603,10 +603,35 @@ 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);
+ /// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发;
+ /// 排货失败不阻断预报,也不建轨迹(可页面补做)。
+ ///
///
- /// 入参:订单包裹创建参数。
- /// 订单包裹创建结果。
+ /// 入参:订单包裹创建参数。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.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..ad4e985 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);
@@ -1206,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
@@ -1223,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) =>
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));
}