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