using System.Collections.Concurrent; 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; using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Labeling; using Oms.Application.Messaging; using Oms.Application.Messaging.OrderSideEffects; using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Application.Services.Orders.Batch; using Oms.Application.Services.Orders.Issues; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.MarketingAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; using Oms.Domain.Shared.Enums; using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Orders; public partial class OrderService { private const string PriceOperatorType = "price"; private const string RulesOperatorType = "rules"; private const int ParcelBatchMaxCount = 2000; private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; internal const string ForecastBillingProcessingReason = "预报计费/排货处理中"; private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报"; private static readonly ConcurrentDictionary InboundBillingInFlight = new(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private ICustomerExportQualificationRepository ExportQualificationRepository => LazyServiceProvider.GetRequiredService(); private IMemberLevelRepository MemberLevelRepository => LazyServiceProvider.GetRequiredService(); private Task ResolvePriceTierKeyAsync(int? customerId) => MarketingCustomerHelper.ResolvePriceTierKeyAsync(CustomerRepository, MemberLevelRepository, customerId); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService(); private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService(); private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService(); private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher => LazyServiceProvider.GetRequiredService(); private AutoTriggerRuntimeService AutoTriggerRuntimeService => LazyServiceProvider.GetRequiredService(); private OrderAutoTriggerService OrderAutoTriggerService => LazyServiceProvider.GetRequiredService(); private sealed class ParcelCreateContext { public string? OrderSource { get; init; } public DateTime? PickupTime { get; init; } public int? CustomerSenderAddressId { get; init; } public string? InboundMethod { get; init; } } private static ParcelCreateContext? BuildParcelCreateContext(OrderParcelCreateInput input) { var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) ? null : input.InboundMethod.Trim(); if (inbound == null) { return null; } var isDoorPickup = string.Equals( inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase); return new ParcelCreateContext { InboundMethod = inbound, PickupTime = isDoorPickup ? input.ExpectPickupTime : null, CustomerSenderAddressId = isDoorPickup ? input.SenderAddressId : null }; } private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, int? operatorUserId = null, ParcelCreateContext? createContext = null) { if (!string.IsNullOrWhiteSpace(input.RequestId)) { var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId); if (existed != null) { return await BuildParcelCreateOutputAsync(existed); } } var bindTime = DateTime.Now; NormalizeParcelCreateInput(input); createContext ??= BuildParcelCreateContext(input); var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId); input.CustomerId = sessionCustomer.Id; await ValidateParcelCreateInputAsync(input); var product = await SalesProductRepository.GetByProductCodeAsync(input.ProductCode!.Trim()); if (product == null || product.IsActive != EnableStatus.Enabled) { throw new BusinessException("销售产品不存在或未启用"); } EnsureAdditionalServicesAllowed(product, input.AdditionalServices); var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); var country = await GetCountryOrThrowAsync(input.Receiver!.CountryCode); if (await IsBlacklistHitAsync( product, snapshotBinding.Rules, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode)) { throw new BusinessException("收件地址命中黑名单区域"); } await OrderAutoTriggerService.EvaluatePreCommitAsync( input, sessionCustomer, country.CountryCode, country.ChineseName); var serviceNo = await ResolveServiceNoAsync(); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); Order? created = null; try { var order = BuildDraftOrderEntity( input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo, createContext); order = await OrderRepository.InsertAsync(order); created = order; var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 if (input.SaveAsDraft) { var draftOutput = await BuildParcelCreateOutputAsync(order); await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); return draftOutput; } // 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。 order.IsRemote = await IsRemoteHitAsync( product, snapshotBinding.Rules, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode) ? (byte)1 : (byte)0; order.OrderStatus = OrderStatusCodes.Draft; order.PredeclareFailReason = ForecastBillingProcessingReason; order.AllocationPendingReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); var output = await BuildParcelCreateOutputAsync(order); await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); return output; } catch (BusinessException) { await TryRollbackFailedParcelCreateAsync(created); throw; } catch (Exception ex) { // 创建失败不落库草稿:回滚已插入订单,由前端让客户选择继续修改或主动保存草稿。 await TryRollbackFailedParcelCreateAsync(created); var cleanReason = StripDraftSavedMarker(ex.Message); if (string.IsNullOrWhiteSpace(cleanReason)) { cleanReason = "预报未完成"; } throw new BusinessException(cleanReason); } } private async Task TryRollbackFailedParcelCreateAsync(Order? created) { if (created == null) { return; } try { await RollbackFailedParcelCreateAsync(created); } catch { // 回滚失败不覆盖原始业务错误;半成品订单需运维清理 } } /// /// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。 /// private async Task RollbackFailedParcelCreateAsync(Order order) { try { var writer = LazyServiceProvider.GetRequiredService(); // 强制清掉可能已写入的 pending 9610(ExportCustomsMode 临时置空触发删除逻辑) var mode = order.ExportCustomsMode; order.ExportCustomsMode = null; await writer.UpsertFromOrderAsync(order); order.ExportCustomsMode = mode; } catch { // ignore } order.OrderStatus = OrderStatusCodes.Deleted; order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); await OrderRepository.DeleteAsync(order); } private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null) { try { await LazyServiceProvider.GetRequiredService() .UpsertFromOrderAsync(order, platformCode); } catch { // 9610 同步失败不阻断运单创建 } } /// /// 已有草稿重新预报失败时,在业务文案末尾附加机器可读标记,供前端区分。 /// 格式:原因 [[omsDraft:{"draftSaved":true,"orderId":1,"serviceNo":"IDE..."}]] /// 同时写入 Exception.Data / ErrorCode,由 OmsException2ResponseConverter 输出到响应 Details。 /// 注意:parcel/create 失败会回滚订单,不会走此标记;仅 CompleteDraftPredeclare 使用。 /// private const string DraftSavedMarkerPrefix = " [[omsDraft:"; private const string DraftSavedMarkerSuffix = "]]"; private static string StripDraftSavedMarker(string? message) { if (string.IsNullOrEmpty(message)) { return string.Empty; } var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal); return idx < 0 ? message : message[..idx].TrimEnd(); } private static string WrapDraftSavedFail(Order order, string reason) { var clean = StripDraftSavedMarker(reason); if (string.IsNullOrWhiteSpace(clean)) { clean = "预报未完成"; } var payload = global::System.Text.Json.JsonSerializer.Serialize(new Dictionary { ["draftSaved"] = true, ["orderId"] = order.Id, ["serviceNo"] = order.ServiceNo ?? string.Empty, ["reason"] = clean, ["errorCode"] = OrderParcelDraftFailData.ErrorCodeValue, }); return $"{clean}{DraftSavedMarkerPrefix}{payload}{DraftSavedMarkerSuffix}"; } /// /// 抛出「草稿已落库」业务异常:ErrorCode + Data 结构化载荷,msg 仍带兼容标记。 /// private static BusinessException CreateDraftSavedBusinessException(Order order, string reason) { var clean = StripDraftSavedMarker(reason); if (string.IsNullOrWhiteSpace(clean)) { clean = "预报未完成"; } var failData = new OrderParcelDraftFailData { DraftSaved = true, OrderId = order.Id, ServiceNo = order.ServiceNo ?? string.Empty, Reason = clean, ErrorCode = OrderParcelDraftFailData.ErrorCodeValue, }; var ex = new BusinessException(WrapDraftSavedFail(order, clean)); ex.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue; ex.Data["draftSaved"] = true; ex.Data["orderId"] = order.Id; ex.Data["serviceNo"] = order.ServiceNo ?? string.Empty; ex.Data[OrderParcelDraftFailData.DataKey] = global::System.Text.Json.JsonSerializer.Serialize(failData); return ex; } private const string ParcelServiceNoPrefix = "IDE"; /// /// 自动生成运单号:IDE + yyMMddHHmmss + 4 位随机数(如 IDE2607221859123456)。 /// private static string BuildParcelServiceNo(DateTime now, int randomSuffix) => $"{ParcelServiceNoPrefix}{now:yyMMddHHmmss}{randomSuffix}"; private static void NormalizeParcelCreateInput(OrderParcelCreateInput input) { input.TrackNo = null; input.ServiceNo = null; input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim(); input.RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim(); if (!string.IsNullOrWhiteSpace(input.CargoAttribute)) { input.CargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoAttribute.Trim()); } } private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input) { if (string.IsNullOrWhiteSpace(input.ClientNo)) { throw new BusinessException("客户单号不能为空"); } if (!input.CustomerId.HasValue || input.CustomerId <= 0) { throw new BusinessException("客户ID不能为空"); } var duplicatedClientNo = await OrderRepository.GetByCustomerAndClientNoAsync( input.CustomerId.Value, input.ClientNo); if (duplicatedClientNo != null) { throw new BusinessException("客户单号已存在"); } if (string.IsNullOrWhiteSpace(input.ProductCode)) { throw new BusinessException("产品代码不能为空"); } if (!input.WarehouseId.HasValue || input.WarehouseId <= 0) { throw new BusinessException("仓库ID不能为空"); } if (!ParcelBillingValidators.HasFullDimensions(input.Length, input.Width, input.Height) || !input.ActualWeight.HasValue || input.ActualWeight <= 0) { throw new BusinessException("件重尺必须大于0"); } if (string.IsNullOrWhiteSpace(input.CargoAttribute) || input.CargoAttribute is not (CargoAttributeCodes.General or CargoAttributeCodes.Electronics or CargoAttributeCodes.Sensitive)) { throw new BusinessException("货物属性不合法"); } if (!string.IsNullOrWhiteSpace(input.ExportCustomsMode) && !ExportCustomsModeCodes.All.Contains(input.ExportCustomsMode)) { throw new BusinessException("出口报关方式不合法"); } var is9610Summary = string.Equals( input.ExportCustomsMode, ExportCustomsModeCodes.Mode9610Summary, StringComparison.OrdinalIgnoreCase); if (is9610Summary) { CustomerExportQualification? qualification = null; if (input.ExportQualificationId is > 0) { qualification = await ExportQualificationRepository.GetOwnedAsync( input.ExportQualificationId.Value, input.CustomerId!.Value); if (qualification == null) { throw new BusinessException("出口资质备案不存在或不属于当前客户"); } } else { qualification = await ExportQualificationRepository.GetDefaultAsync(input.CustomerId!.Value); if (qualification == null) { throw new BusinessException("9610汇总申报必须选择出口资质备案(或先维护默认备案)"); } input.ExportQualificationId = qualification.Id; } if (string.IsNullOrWhiteSpace(input.PlatformCode)) { throw new BusinessException("9610汇总申报必须填写电商平台"); } input.PlatformCode = input.PlatformCode.Trim(); if (input.PlatformCode.Length > 16) { throw new BusinessException("电商平台长度不能超过 16"); } } else { input.ExportQualificationId = null; input.PlatformCode = null; } if (string.IsNullOrWhiteSpace(input.DeclaredCurrency)) { throw new BusinessException("申报币种不能为空"); } if (!input.Quantity.HasValue || input.Quantity <= 0) { throw new BusinessException("包裹数量不能为空"); } if (input.Receiver == null || string.IsNullOrWhiteSpace(input.Receiver.RecipientsName) || string.IsNullOrWhiteSpace(input.Receiver.CountryCode) || string.IsNullOrWhiteSpace(input.Receiver.ZipCode) || string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1)) { throw new BusinessException("收件人信息不完整"); } if (input.Sender == null || string.IsNullOrWhiteSpace(input.Sender.SenderName)) { throw new BusinessException("发件人姓名不能为空"); } if (input.Items.Count == 0) { throw new BusinessException("申报明细不能为空"); } foreach (var item in input.Items) { if (string.IsNullOrWhiteSpace(item.EnName) || !item.UnitPrice.HasValue || !item.UnitWeight.HasValue || !item.Quantity.HasValue || item.Quantity <= 0) { throw new BusinessException("申报明细必填字段不完整"); } } var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value); if (warehouse == null) { throw new BusinessException("仓库不存在"); } var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value); if (customer == null || customer.Status != CustomerStatus.Normal) { throw new BusinessException("客户不存在或状态不可用"); } CustomerContactHelper.EnsureContactComplete(customer); } private async Task ResolveSnapshotBindingAsync(SalesProduct product, DateTime bindTime) { var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime); var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime); SalesProductRulesPublishSnapshotDto rulesSnapshot; if (rulesRecord == null) { rulesSnapshot = await BuildLiveRulesSnapshotAsync(product); } else { rulesSnapshot = OmsDocHelper.DeserializeRulesSnapshot(rulesRecord.Content) ?? throw new BusinessException("未命中规则快照版本(内存/Redis/DB)"); } SalesProductPricePublishSnapshotDto priceSnapshot; if (priceRecord == null) { priceSnapshot = await BuildLivePriceSnapshotAsync(product); if (priceSnapshot.Prices.Count == 0) { throw new BusinessException("未命中价格快照版本(内存/Redis/DB)"); } } else { priceSnapshot = OmsDocHelper.DeserializePriceSnapshot(priceRecord.Content) ?? throw new BusinessException("未命中价格快照版本(内存/Redis/DB)"); if (priceSnapshot.Prices.Count == 0) { throw new BusinessException("未命中价格快照版本(内存/Redis/DB)"); } } if (rulesRecord == null && !product.WeightRuleId.HasValue) { throw new BusinessException("未命中规则快照版本(内存/Redis/DB)"); } var binding = new ProductSnapshotBinding { RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}", PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}", RulesEffectiveTime = rulesRecord?.PublishTime ?? bindTime, PriceEffectiveTime = priceRecord?.PublishTime ?? bindTime, Rules = rulesSnapshot, Price = priceSnapshot }; await KpiAreaPackService.HydrateBindingAsync(binding); return binding; } private async Task ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order) { var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime; SalesProductRulesPublishSnapshotDto rulesSnapshot; string rulesVersion; DateTime rulesEffectiveTime; if (string.IsNullOrWhiteSpace(order.SalesProductRulesVersion) || order.SalesProductRulesVersion.StartsWith("LIVE-", StringComparison.OrdinalIgnoreCase)) { rulesSnapshot = await BuildLiveRulesSnapshotAsync(product); rulesVersion = order.SalesProductRulesVersion ?? $"LIVE-{product.Id}"; rulesEffectiveTime = bindTime; } else { var rulesRecord = await SalesProductRepository.GetPublishRecordByVersionAsync( product.Id, RulesOperatorType, order.SalesProductRulesVersion); if (rulesRecord?.Content == null) { throw new BusinessException("未命中订单绑定的规则快照版本"); } rulesSnapshot = OmsDocHelper.DeserializeRulesSnapshot(rulesRecord.Content) ?? throw new BusinessException("未命中订单绑定的规则快照版本"); rulesVersion = rulesRecord.Version; rulesEffectiveTime = rulesRecord.PublishTime; } SalesProductPricePublishSnapshotDto priceSnapshot; string priceVersion; DateTime priceEffectiveTime; if (string.IsNullOrWhiteSpace(order.SalesProductPriceVersion) || order.SalesProductPriceVersion.StartsWith("LIVE-", StringComparison.OrdinalIgnoreCase)) { priceSnapshot = await BuildLivePriceSnapshotAsync(product); if (priceSnapshot.Prices.Count == 0) { throw new BusinessException("未命中订单绑定的价格快照版本"); } priceVersion = order.SalesProductPriceVersion ?? $"LIVE-{product.Id}"; priceEffectiveTime = bindTime; } else { var priceRecord = await SalesProductRepository.GetPublishRecordByVersionAsync( product.Id, PriceOperatorType, order.SalesProductPriceVersion); if (priceRecord?.Content == null) { throw new BusinessException("未命中订单绑定的价格快照版本"); } priceSnapshot = OmsDocHelper.DeserializePriceSnapshot(priceRecord.Content) ?? throw new BusinessException("未命中订单绑定的价格快照版本"); if (priceSnapshot.Prices.Count == 0) { throw new BusinessException("未命中订单绑定的价格快照版本"); } priceVersion = priceRecord.Version; priceEffectiveTime = priceRecord.PublishTime; } var binding = new ProductSnapshotBinding { RulesVersion = rulesVersion, PriceVersion = priceVersion, RulesEffectiveTime = rulesEffectiveTime, PriceEffectiveTime = priceEffectiveTime, Rules = rulesSnapshot, Price = priceSnapshot }; await KpiAreaPackService.HydrateBindingAsync(binding); return binding; } private async Task ResolveWarehouseNameAsync(int? warehouseId) { if (!warehouseId.HasValue || warehouseId.Value <= 0) { return null; } var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value); return warehouse?.WarehouseName; } private async Task ResolveOrderBillingAddressAsync(Order order) { var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); var recipient = recipients.FirstOrDefault(); return new OrderBillingAddress( order.ConsigneeCountry ?? string.Empty, recipient?.Province, recipient?.City, recipient?.ZipCode); } private async Task> ResolveZoneKeysOrThrowAsync( SalesProduct product, ProductSnapshotBinding snapshotBinding, OrderBillingAddress address) { var zoneKeys = await ResolvePriceZoneKeysAsync( product, snapshotBinding.Rules, snapshotBinding.Price, address.CountryCode, address.Province, address.City, address.ZipCode); if (zoneKeys == null || zoneKeys.Count == 0) { throw new BusinessException("价格分区未匹配(规则快照)"); } return zoneKeys; } private async Task ResolveBillingContextAsync( SalesProduct product, ProductSnapshotBinding snapshotBinding, OrderBillingAddress address, decimal chargeableWeight, string cargoAttribute, int? warehouseId, string? priceTierKey = null) { if (chargeableWeight <= 0) { throw new BusinessException("计费重无效,无法计费"); } var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, snapshotBinding, address); var warehouseName = await ResolveWarehouseNameAsync(warehouseId); return new OrderBillingContext { SnapshotBinding = snapshotBinding, ZoneKeys = zoneKeys, ChargeableWeight = chargeableWeight, CargoAttribute = cargoAttribute, PriceTierKey = priceTierKey, WarehouseId = warehouseId, WarehouseName = warehouseName }; } /// /// 解析已有订单的算价上下文(绑定订单快照版本)。 /// private async Task ResolveOrderBillingContextAsync( Order order, SalesProduct product, decimal chargeableWeight, ProductSnapshotBinding? snapshotBinding = null) { if (chargeableWeight <= 0) { throw new BusinessException("计费重无效,无法计费"); } var binding = snapshotBinding ?? await ResolveSnapshotBindingFromOrderAsync(product, order); var address = await ResolveOrderBillingAddressAsync(order); var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, binding, address); var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId); var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); return new OrderBillingContext { SnapshotBinding = binding, ZoneKeys = zoneKeys, ChargeableWeight = chargeableWeight, CargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General, PriceTierKey = priceTierKey, WarehouseId = order.WarehouseId, WarehouseName = warehouseName }; } private async Task UpsertReceivableFeesAsync( Order order, OrderBillingContext billing, string chargingStage, bool replaceExistingReceivable = false) { await UpsertReceivableFeesAsync( order, billing.SnapshotBinding, billing.ChargeableWeight, billing.ZoneKeys, billing.CargoAttribute, billing.PriceTierKey, chargingStage, replaceExistingReceivable, billing.WarehouseId, billing.WarehouseName); } private async Task UpsertReceivableFeesAsync( Order order, ProductSnapshotBinding snapshotBinding, decimal chargeableWeight, IReadOnlyList zoneKeys, string cargoAttribute, string? priceTierKey, string chargingStage, bool replaceExistingReceivable = false, int? warehouseId = null, string? warehouseName = null) { var conditionContext = BuildBillingConditionContext(order); var remoteAreaId = order.IsRemote > 0 ? (snapshotBinding.Rules?.RemoteAreaId ?? snapshotBinding.Rules?.Product?.RemoteAreaId) : null; var remoteZoneKeys = ParcelBillingCalculator.BuildRemoteZoneKeys( remoteAreaId is > 0 ? remoteAreaId : null, snapshotBinding.Rules?.RemoteAreaName); var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshotBinding.Price, snapshotBinding.PriceVersion, snapshotBinding.PriceEffectiveTime, chargeableWeight, zoneKeys, cargoAttribute, priceTierKey, chargingStage, warehouseId ?? order.WarehouseId, warehouseName, conditionContext, order.DeclaredSum, order.DeclaredCurrency, remoteZoneKeys); if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price)) { if (chargeableWeight <= 0) { throw new BusinessException("计费重无效,无法计算应收费用"); } var detail = ParcelBillingCalculator.ExplainQuotePriceMiss( snapshotBinding.Price, chargeableWeight, zoneKeys, cargoAttribute, priceTierKey, warehouseId ?? order.WarehouseId, warehouseName); throw new BusinessException($"产品卖价未匹配到有效价格:{detail}"); } if (fees.Count > 0) { if (replaceExistingReceivable) { // 有效应收只保留一套:清空全部阶段后再写入当前阶段,避免预报+入库叠算。 await OrderRepository.DeleteReceivableFeesAsync(order.Id); } await OrderRepository.UpsertFeesAsync(order.Id, fees); } } /// /// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。 /// 仅用于入库、重算应付等内部环节;创建订单时不调用。 /// private async Task ConfirmServiceChannelAndPayableAsync( Order order, SalesProduct product, ProductSnapshotBinding snapshotBinding, IReadOnlyList zoneKeys, decimal chargeableWeight, string chargingStage, string? _, string? schemeOverride = null, int? forceServiceChannelId = null, bool allowDesignatedOutsidePool = false) { var evaluation = await EvaluateAllocationAsync( order, product, snapshotBinding, zoneKeys, chargeableWeight, chargingStage, schemeOverride, forceServiceChannelId, allowDesignatedOutsidePool); ApplyAllocationTraceToOrder(order, evaluation); if (!string.IsNullOrWhiteSpace(evaluation.FailReason) || evaluation.Selected == null) { throw new BusinessException(evaluation.FailReason ?? "排货规则未匹配到可用服务渠道"); } var selectedCandidate = evaluation.Selected; var payableFees = selectedCandidate.PayableFees.ToList(); if (payableFees.Count == 0) { evaluation.FailReason = "渠道买价未配置或未匹配到有效价格"; ApplyAllocationTraceToOrder(order, evaluation); throw new BusinessException(evaluation.FailReason); } var previousChannelCode = order.ChannelCode?.Trim(); var newChannelCode = selectedCandidate.Channel.ChannelCode?.Trim() ?? string.Empty; var channelChanged = !string.Equals( previousChannelCode, newChannelCode, StringComparison.OrdinalIgnoreCase); order.ChannelCode = selectedCandidate.Channel.ChannelCode; AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder( order, selectedCandidate.Provider, selectedCandidate.Channel); // 换渠道后旧下游单号/跟踪号失效,清空以免「新渠道+旧服务商单号」。 if (channelChanged && (!string.IsNullOrWhiteSpace(order.DownstreamServiceNo) || !string.IsNullOrWhiteSpace(order.TrackNo))) { order.DownstreamServiceNo = null; order.TrackNo = null; } // 有效应付只保留本阶段一套,避免 forecast+inbound 叠行虚增成本。 await OrderRepository.DeletePayableFeesAsync(order.Id); await OrderRepository.UpsertFeesAsync(order.Id, payableFees); order.AllocationPendingReason = string.Empty; } private sealed class AllocationEvaluation { public string StoredScheme { get; init; } = AllocationSchemeCodes.CostLowest; public string SelectScheme { get; init; } = AllocationSchemeCodes.CostLowest; public string SchemeSource { get; init; } = "productDefault"; public decimal ChargeableWeight { get; init; } public string ChargingStage { get; init; } = string.Empty; public IReadOnlyList ZoneKeys { get; init; } = []; public List Candidates { get; init; } = []; public List Skipped { get; init; } = []; public int PoolCount { get; init; } public int ConditionPassCount { get; init; } public AllocationResolver.ChannelCandidate? Selected { get; set; } public string? FailReason { get; set; } } private async Task EvaluateAllocationAsync( Order order, SalesProduct product, ProductSnapshotBinding snapshotBinding, IReadOnlyList zoneKeys, decimal chargeableWeight, string chargingStage, string? schemeOverride, int? forceServiceChannelId, bool allowDesignatedOutsidePool = false) { var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); // 判断条件需要客户名、件重尺等,与自动触发共用完整上下文 var ruleContext = await OrderAutoTriggerService.BuildPostCommitContextAsync(order); var filterResult = AllocationResolver.FilterProvidersDetailed( providers, order.CustomerId, ruleContext); var skipped = filterResult.Skipped.ToList(); var filtered = filterResult.Passed; var poolCount = filterResult.PoolCount; var conditionPassCount = filterResult.ConditionPassCount; var productScheme = snapshotBinding.Rules.Product?.Scheme ?? product.Scheme ?? AllocationSchemeCodes.CostLowest; var hasOverride = !string.IsNullOrWhiteSpace(schemeOverride); var isDesignated = forceServiceChannelId is > 0 || AllocationSchemeCodes.IsDesignated(schemeOverride); var storedScheme = isDesignated ? AllocationSchemeCodes.Designated : hasOverride ? schemeOverride!.Trim() : productScheme; var schemeSource = isDesignated ? "designated" : hasOverride ? "reallocate" : "productDefault"; var selectScheme = AllocationSchemeCodes.IsDesignated(storedScheme) ? AllocationSchemeCodes.CostLowest : storedScheme; AllocationEvaluation BaseEval(string? failReason = null) => new() { StoredScheme = storedScheme, SelectScheme = selectScheme, SchemeSource = schemeSource, ChargeableWeight = chargeableWeight, ChargingStage = chargingStage, ZoneKeys = zoneKeys, Skipped = skipped, PoolCount = poolCount, ConditionPassCount = conditionPassCount, FailReason = failReason }; if (forceServiceChannelId is > 0) { var designated = filtered .Where(x => x.ServiceChannelId == forceServiceChannelId.Value) .ToList(); if (designated.Count == 0) { var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value); // 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。 if (allowDesignatedOutsidePool) { var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == forceServiceChannelId.Value); if (lockedChannel == null) { return BaseEval("订单服务渠道不存在,无法重算应付"); } var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto { SalesProductId = product.Id, ServiceChannelId = lockedChannel.Id, ServiceChannelName = lockedChannel.ChannelName, ProviderName = lockedChannel.ChannelName ?? lockedChannel.ChannelCode, ChannelCode = lockedChannel.ChannelCode, Weight = 0, CustomerIds = [], MatchCondition = null }; if (poolRow == null || AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment(lockedProvider)) { await AllocationProviderSnapshotEnricher.EnrichAsync([lockedProvider]); } filtered = [lockedProvider]; } else { var failReason = poolRow == null ? "指定渠道不在该销售产品排货池" : "指定渠道不满足判断条件"; if (poolRow != null && skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value)) { skipped.Add(new OrderAllocationSkippedOutput { ServiceChannelId = forceServiceChannelId.Value, ChannelCode = poolRow.ChannelCode, ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName, Stage = AllocationResolver.SkipStageCondition, MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition), Reason = failReason }); } return BaseEval(failReason); } } else { filtered = designated; } } if (filtered.Count == 0) { var conditionMiss = skipped.Count(x => x.Stage is AllocationResolver.SkipStageCondition or AllocationResolver.SkipStageCustomer); var failReason = poolCount == 0 ? "排货规则未配置可用服务渠道" : conditionMiss > 0 ? $"排货规则未匹配到可用服务渠道(条件未命中 {conditionMiss}/{poolCount})" : "排货规则未匹配到可用服务渠道"; return BaseEval(failReason); } // 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。 var bindTime = DateTime.Now; var candidates = new List(); var recipient = ruleContext.Recipient ?? (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault(); var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General; foreach (var provider in filtered) { var channelId = provider.ServiceChannelId!.Value; var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId); if (channel == null) { skipped.Add(new OrderAllocationSkippedOutput { ServiceChannelId = channelId, Stage = AllocationResolver.SkipStageChannel, MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), Reason = "服务渠道不存在" }); continue; } if (channel.IsActive != EnableStatus.Enabled) { skipped.Add(new OrderAllocationSkippedOutput { ServiceChannelId = channel.Id, ChannelCode = channel.ChannelCode, ChannelName = channel.ChannelName, Stage = AllocationResolver.SkipStageChannel, MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), Reason = "服务渠道未启用" }); continue; } var channelRulesBinding = await ResolveChannelRulesBindingAsync(channel, bindTime); var channelIsRemote = IsChannelRemoteHit( channelRulesBinding, order.ConsigneeCountry ?? string.Empty, recipient?.Province, recipient?.City, recipient?.ZipCode); var payableContext = BuildBillingConditionContext( order, channelIsRemote ? (byte)1 : (byte)0); var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime); var channelZoneKeys = ResolveChannelZoneKeysOrSkip( channelPriceBinding, order.ConsigneeCountry ?? string.Empty, recipient?.Province, recipient?.City, recipient?.ZipCode, out var channelZoneMissReason); if (channelZoneKeys == null) { skipped.Add(new OrderAllocationSkippedOutput { ServiceChannelId = channel.Id, ChannelCode = channel.ChannelCode, ChannelName = channel.ChannelName, Stage = AllocationResolver.SkipStageBuyPrice, MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), Reason = channelZoneMissReason ?? $"渠道买价分区未匹配(版本 {channelPriceBinding.PriceVersion})" }); continue; } var channelRemoteZoneKeys = channelIsRemote ? ParcelBillingCalculator.BuildRemoteZoneKeys( channelRulesBinding.RemoteAreaId ?? channel.RemoteAreaId) : Array.Empty(); var previewFees = ParcelBillingCalculator.CalculatePayableFees( channelPriceBinding.Prices, channelPriceBinding.PriceVersion, channelPriceBinding.PriceEffectiveTime, chargeableWeight, channelZoneKeys, cargoAttribute, chargingStage, order.WarehouseId, warehouseName, payableContext, order.DeclaredSum, order.DeclaredCurrency, channelRemoteZoneKeys); if (previewFees.Count == 0) { var miss = ParcelBillingCalculator.ExplainPayablePriceMiss( channelPriceBinding.Prices, chargeableWeight, channelZoneKeys, cargoAttribute, order.WarehouseId, warehouseName, channelRemoteZoneKeys); skipped.Add(new OrderAllocationSkippedOutput { ServiceChannelId = channel.Id, ChannelCode = channel.ChannelCode, ChannelName = channel.ChannelName, Stage = AllocationResolver.SkipStageBuyPrice, MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition), Reason = string.IsNullOrWhiteSpace(miss) ? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})" : $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}" }); continue; } candidates.Add(new AllocationResolver.ChannelCandidate { Provider = provider, Channel = channel, TotalPayableCost = previewFees.Sum(x => x.Amount), PayableFees = previewFees }); } if (candidates.Count == 0) { var buyPriceMiss = skipped.Count(x => x.Stage == AllocationResolver.SkipStageBuyPrice); var detail = skipped.FirstOrDefault(x => x.Stage == AllocationResolver.SkipStageBuyPrice && !string.IsNullOrWhiteSpace(x.Reason))?.Reason; var failReason = chargeableWeight <= 0 ? "计费重无效,无法计算应付费用" : buyPriceMiss > 0 ? (string.IsNullOrWhiteSpace(detail) ? $"排货规则未匹配到有效渠道买价(买价未命中 {buyPriceMiss})" : $"排货规则未匹配到有效渠道买价;{detail}") : "排货规则未匹配到有效渠道买价"; return new AllocationEvaluation { StoredScheme = storedScheme, SelectScheme = selectScheme, SchemeSource = schemeSource, ChargeableWeight = chargeableWeight, ChargingStage = chargingStage, ZoneKeys = zoneKeys, Candidates = candidates, Skipped = skipped, PoolCount = poolCount, ConditionPassCount = conditionPassCount, FailReason = failReason }; } var selected = AllocationResolver.SelectCandidate(selectScheme, candidates); return new AllocationEvaluation { StoredScheme = storedScheme, SelectScheme = selectScheme, SchemeSource = schemeSource, ChargeableWeight = chargeableWeight, ChargingStage = chargingStage, ZoneKeys = zoneKeys, Candidates = candidates, Skipped = skipped, PoolCount = poolCount, ConditionPassCount = conditionPassCount, Selected = selected }; } private static void ApplyAllocationTraceToOrder(Order order, AllocationEvaluation evaluation) { var selectedCode = evaluation.Selected?.Channel.ChannelCode; var output = BuildAllocationTraceOutput( order.Id, evaluation, fromSnapshot: true, selectedChannelCode: selectedCode); order.AllocationScheme = evaluation.StoredScheme; order.AllocationTraceJson = JsonConvert.SerializeObject(output); order.AllocationTraceTime = DateTime.Now; } private static OrderAllocationTraceOutput BuildAllocationTraceOutput( int orderId, AllocationEvaluation evaluation, bool fromSnapshot, string? selectedChannelCode) { var selectedId = evaluation.Selected?.Channel.Id; var candidates = evaluation.Candidates .Select(c => new OrderAllocationCandidateOutput { ServiceChannelId = c.Channel.Id, ChannelCode = c.Channel.ChannelCode, ChannelName = c.Channel.ChannelName, TotalPayableCost = c.TotalPayableCost, MinTime = c.Channel.MinTime, Weight = c.Provider.Weight, Selected = selectedId.HasValue && c.Channel.Id == selectedId.Value, FeeLines = c.PayableFees.Select(f => new OrderAllocationFeeLineOutput { CostItem = f.CostItem, Amount = f.Amount, Currency = f.Currency }).ToList() }) .ToList(); // 按方案主指标排序,选中置顶 candidates = SortAllocationCandidates(evaluation.StoredScheme, candidates); return new OrderAllocationTraceOutput { OrderId = orderId, FromSnapshot = fromSnapshot, Scheme = evaluation.StoredScheme, SchemeName = ResolveAllocationSchemeName(evaluation.StoredScheme), SchemeSource = evaluation.SchemeSource, SchemeSourceName = ResolveAllocationSchemeSourceName(evaluation.SchemeSource), ChargeableWeight = evaluation.ChargeableWeight, ZoneKeys = evaluation.ZoneKeys?.ToList() ?? [], ChargingStage = evaluation.ChargingStage, ChargingStageName = ResolveChargingStageName(evaluation.ChargingStage), SelectedChannelCode = selectedChannelCode ?? evaluation.Selected?.Channel.ChannelCode, SelectedChannelName = evaluation.Selected?.Channel.ChannelName, FailReason = evaluation.FailReason, TraceTime = DateTime.Now, PoolCount = evaluation.PoolCount, ConditionPassCount = evaluation.ConditionPassCount, CandidateCount = candidates.Count, Candidates = candidates, Skipped = evaluation.Skipped }; } private static List SortAllocationCandidates( string? scheme, List candidates) { Func metric = scheme switch { AllocationSchemeCodes.TimeFastest => x => x.MinTime ?? int.MaxValue, AllocationSchemeCodes.OverallBest => x => -x.Weight, _ => x => x.TotalPayableCost }; return candidates .OrderByDescending(x => x.Selected) .ThenBy(metric) .ThenBy(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase) .ToList(); } private static string ResolveAllocationSchemeName(string? scheme) => scheme switch { AllocationSchemeCodes.TimeFastest => "时效优先", AllocationSchemeCodes.OverallBest => "综合最优", AllocationSchemeCodes.Designated => "指定渠道", _ => "价格优先" }; private static string ResolveAllocationSchemeSourceName(string? source) => source switch { "reallocate" => "运营重新排货", "designated" => "指定渠道", _ => "产品默认" }; private static string ResolveChargingStageName(string? stage) => stage switch { OrderFeeChargingStageCodes.Inbound => "入库", OrderFeeChargingStageCodes.Outbound => "出库", OrderFeeChargingStageCodes.Forecast => "预报", _ => stage ?? string.Empty }; private Task ConfirmServiceChannelAndPayableAsync( Order order, SalesProduct product, OrderBillingContext billing, string chargingStage, string? schemeOverride = null, int? forceServiceChannelId = null, bool allowDesignatedOutsidePool = false) => ConfirmServiceChannelAndPayableAsync( order, product, billing.SnapshotBinding, billing.ZoneKeys, billing.ChargeableWeight, chargingStage, billing.PriceTierKey, schemeOverride, forceServiceChannelId, allowDesignatedOutsidePool); /// /// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。 /// private async Task TryConfirmServiceChannelAndPayableAsync( Order order, SalesProduct product, OrderBillingContext billing, string chargingStage, string? schemeOverride = null, int? forceServiceChannelId = null, bool allowDesignatedOutsidePool = false) { try { await ConfirmServiceChannelAndPayableAsync( order, product, billing, chargingStage, schemeOverride, forceServiceChannelId, allowDesignatedOutsidePool); return true; } catch (BusinessException ex) { var message = ex.Message.Trim(); order.AllocationPendingReason = message.Length > 512 ? message[..512] : message; return false; } } private async Task> ResolveAllocationProvidersAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto rulesSnapshot) { List providers; if (rulesSnapshot.Providers.Count > 0) { providers = rulesSnapshot.Providers; if (providers.Any(AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment)) { await AllocationProviderSnapshotEnricher.EnrichAsync(providers); } } else { var entities = await SalesProductRepository.GetAllocationProvidersAsync(product.Id); var channelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray(); var channels = channelIds.Length == 0 ? [] : await ServiceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id)); var channelMap = channels.ToDictionary(x => x.Id, x => x.ChannelName); providers = entities.Select(provider => PublishSnapshotSlimmer.SlimAllocationProvider(new SalesProductAllocationProviderOutput { Id = provider.Id, SalesProductId = provider.SalesProductId, SerialNumber = provider.SerialNumber, ProviderName = provider.ProviderName, ServiceChannelId = provider.ServiceChannelId, ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null, Weight = provider.Weight, CustomerIds = OmsDocHelper.DeserializeJson>(provider.CustomerIds) ?? [], MatchCondition = provider.MatchCondition })).ToList(); await AllocationProviderSnapshotEnricher.EnrichAsync(providers); } return providers; } private sealed class ChannelPriceBinding { public List Prices { get; init; } = []; public string PriceVersion { get; init; } = string.Empty; public DateTime PriceEffectiveTime { get; init; } public ServiceChannelPricePublishSnapshotDto Snapshot { get; init; } = new(); } private sealed class ChannelRulesBinding { public int? RemoteAreaId { get; init; } public ServiceChannelRulesPublishSnapshotDto Snapshot { get; init; } = new(); } private async Task ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime) { var priceRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( serviceChannelId, PriceOperatorType, bindTime); if (priceRecord?.Content != null) { var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(priceRecord.Content); if (snapshot?.Prices.Count > 0) { await KpiAreaPackService.HydrateChannelPriceSnapshotAsync(snapshot); return new ChannelPriceBinding { Prices = snapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(), PriceVersion = priceRecord.Version, PriceEffectiveTime = priceRecord.PublishTime, Snapshot = snapshot }; } } var liveSnapshot = await BuildLiveChannelPriceSnapshotAsync(serviceChannelId); return new ChannelPriceBinding { Prices = liveSnapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(), PriceVersion = $"LIVE-{serviceChannelId}", PriceEffectiveTime = bindTime, Snapshot = liveSnapshot }; } private async Task ResolveChannelRulesBindingAsync( ServiceChannel channel, DateTime bindTime) { var rulesRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( channel.Id, RulesOperatorType, bindTime); if (rulesRecord?.Content != null) { var snapshot = OmsDocHelper.DeserializeJson(rulesRecord.Content); if (snapshot != null) { await KpiAreaPackService.HydrateChannelRulesSnapshotAsync(snapshot); return new ChannelRulesBinding { RemoteAreaId = snapshot.RemoteAreaRef?.AreaId ?? snapshot.RemoteAreaId ?? snapshot.Channel?.RemoteAreaId ?? channel.RemoteAreaId, Snapshot = snapshot }; } } var live = new ServiceChannelRulesPublishSnapshotDto { Channel = new ServiceChannelSaveInput { Id = channel.Id, RemoteAreaId = channel.RemoteAreaId }, RemoteAreaInfos = [] }; await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync( live, channel.RemoteAreaId); return new ChannelRulesBinding { RemoteAreaId = channel.RemoteAreaId, Snapshot = live }; } private async Task BuildLiveChannelPriceSnapshotAsync( int serviceChannelId) { var snapshot = new ServiceChannelPricePublishSnapshotDto { ServiceChannelId = serviceChannelId, Prices = (await BuildChannelPriceOutputsAsync(serviceChannelId)) .Select(PublishSnapshotSlimmer.SlimChannelPrice) .ToList() }; await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync( snapshot, KpiAreaRepository, KpiAreaPackService); return snapshot; } /// /// 按渠道买价价区包解析 zoneKeys;有价区配置但未命中时返回 null(跳过该渠道)。 /// 无价区配置时返回空列表(不回落产品卖价分区)。 /// private static IReadOnlyList? ResolveChannelZoneKeysOrSkip( ChannelPriceBinding binding, string countryCode, string? province, string? city, string? zipCode, out string? missReason) { missReason = null; var snapshot = binding.Snapshot; if (snapshot.PriceAreaRefs is { Count: > 0 } && snapshot.PriceAreaInfos is not { Count: > 0 }) { missReason = $"渠道价格分区包未加载: refs={snapshot.PriceAreaRefs.Count} version={binding.PriceVersion}"; return null; } if (snapshot.PriceAreaInfos is not { Count: > 0 }) { return Array.Empty(); } var keys = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot( snapshot, countryCode, province, city, zipCode); if (keys == null || keys.Count == 0) { missReason = $"渠道买价分区未匹配(版本 {binding.PriceVersion})"; return null; } return keys; } private static bool IsChannelRemoteHit( ChannelRulesBinding binding, string countryCode, string? province, string? city, string? zipCode) { var snapshot = binding.Snapshot; if (snapshot.RemoteAreaRef != null && snapshot.RemoteAreaInfos is not { Count: > 0 }) { throw new BusinessException( $"渠道偏远分区包未加载: areaId={snapshot.RemoteAreaRef.AreaId} version={snapshot.RemoteAreaRef.PackVersion}"); } if (snapshot.RemoteAreaInfos is { Count: > 0 }) { return GeoPartitionMatcher.IsHit( snapshot.RemoteAreaInfos, countryCode, province, city, zipCode); } return false; } private async Task BuildLiveRulesSnapshotAsync(SalesProduct product) { var snapshot = new SalesProductRulesPublishSnapshotDto { Product = new SalesProductSaveInput { Id = product.Id, ProductCode = product.ProductCode, ProductName = product.ProductName, BlacklistAreaId = product.BlacklistAreaId, RemoteAreaId = product.RemoteAreaId, PriceAreaId = product.PriceAreaId, WeightRuleId = product.WeightRuleId, WeightRoundingId = product.WeightRoundingId, IsActive = product.IsActive, Scheme = product.Scheme } }; await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync( snapshot, product, KpiAreaRepository, WeightRuleRepository, WeightRoundingRepository, KpiAreaPackService); snapshot.Providers = await ResolveAllocationProvidersAsync(product, snapshot); return snapshot; } private async Task BuildLivePriceSnapshotAsync(SalesProduct product) { var prices = await SalesProductRepository.GetPricesAsync(product.Id); var priceIds = prices.Select(x => x.Id).ToArray(); var factorMap = (await SalesProductRepository.GetPriceFactorsAsync(priceIds)) .GroupBy(x => x.PriceId) .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); var detailMap = (await SalesProductRepository.GetPriceDetailsAsync(priceIds)) .GroupBy(x => x.PriceId) .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); var snapshot = new SalesProductPricePublishSnapshotDto { SalesProductId = product.Id, Prices = prices.Select(price => PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput { Id = price.Id, SalesProductId = price.SalesProductId, SerialNumber = price.SerialNumber, Status = (byte)price.Status, PriceName = price.PriceName, CostItem = price.CostItem, CostUnit = price.CostUnit, Currency = price.Currency, ChargingCondition = price.ChargingCondition, Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [], Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [] })).ToList() }; await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync( snapshot, product, KpiAreaRepository, KpiAreaPackService); return snapshot; } private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity) { return new ChannelPriceFactorOutput { Id = entity.Id, PriceId = entity.PriceId, SerialNumber = entity.SerialNumber, Factor = entity.Factor, FactorValues = OmsDocHelper.DeserializeJson>(entity.FactorValues) ?? [] }; } private static ChannelPriceDetailOutput MapPriceDetailOutput(SalesProductPriceDetail entity) { return new ChannelPriceDetailOutput { Id = entity.Id, PriceId = entity.PriceId, FactorValuesJson = OmsDocHelper.DeserializeJsonObject(entity.FactorValuesJson), CalculationFormula = entity.CalculationFormula, CalculatedValue = entity.CalculatedValue, CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(entity.CustomerGroupValues) }; } private Task IsBlacklistHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, string? province, string? city, string? zipCode) { if (rulesSnapshot?.BlacklistAreaRef != null && rulesSnapshot.BlacklistAreaInfos is not { Count: > 0 }) { throw new BusinessException( $"黑名单分区包未加载: areaId={rulesSnapshot.BlacklistAreaRef.AreaId} version={rulesSnapshot.BlacklistAreaRef.PackVersion}"); } if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 }) { return Task.FromResult(GeoPartitionMatcher.IsHit( rulesSnapshot.BlacklistAreaInfos, countryCode, province, city, zipCode)); } return Task.FromResult(false); } private Task IsRemoteHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, string? province, string? city, string? zipCode) { if (rulesSnapshot?.RemoteAreaRef != null && rulesSnapshot.RemoteAreaInfos is not { Count: > 0 }) { throw new BusinessException( $"偏远分区包未加载: areaId={rulesSnapshot.RemoteAreaRef.AreaId} version={rulesSnapshot.RemoteAreaRef.PackVersion}"); } if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 }) { return Task.FromResult(GeoPartitionMatcher.IsHit( rulesSnapshot.RemoteAreaInfos, countryCode, province, city, zipCode)); } return Task.FromResult(false); } private static int? ResolveEffectiveBlacklistAreaId( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot) { if (product.BlacklistAreaId is > 0) { return product.BlacklistAreaId; } if (rulesSnapshot?.BlacklistAreaId is > 0) { return rulesSnapshot.BlacklistAreaId; } if (rulesSnapshot?.Product?.BlacklistAreaId is > 0) { return rulesSnapshot.Product.BlacklistAreaId; } return null; } private static int? ResolveEffectiveRemoteAreaId( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot) { if (product.RemoteAreaId is > 0) { return product.RemoteAreaId; } if (rulesSnapshot?.RemoteAreaId is > 0) { return rulesSnapshot.RemoteAreaId; } if (rulesSnapshot?.Product?.RemoteAreaId is > 0) { return rulesSnapshot.Product.RemoteAreaId; } return null; } private static int? ResolveEffectivePriceAreaId( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, SalesProductPricePublishSnapshotDto? priceSnapshot) { if (product.PriceAreaId is > 0) { return product.PriceAreaId; } if (rulesSnapshot?.Product?.PriceAreaId is > 0) { return rulesSnapshot.Product.PriceAreaId; } if (priceSnapshot?.PriceAreaId is > 0) { return priceSnapshot.PriceAreaId; } return null; } private Task?> ResolvePriceZoneKeysAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, SalesProductPricePublishSnapshotDto? priceSnapshot, string countryCode, string? province, string? city, string? zipCode) { if (priceSnapshot?.PriceAreaRefs is { Count: > 0 } && priceSnapshot.PriceAreaInfos is not { Count: > 0 }) { throw new BusinessException("价格分区包未加载,无法匹配分区"); } if (priceSnapshot?.PriceAreaInfos is { Count: > 0 }) { return Task.FromResult(ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( priceSnapshot, countryCode, province, city, zipCode)); } // 无分区配置:返回 null;禁止回读线上 KPI(正式路径须走包) return Task.FromResult?>(null); } private async Task ApplyChargeableWeightAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, OrderWeight forecast) { try { var weightRuleId = ResolveEffectiveWeightRuleId(product, rulesSnapshot); if (!weightRuleId.HasValue) { throw new BusinessException("未配置计重规则"); } IReadOnlyList ruleInfos; if (rulesSnapshot?.WeightRuleInfos is { Count: > 0 }) { ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(rulesSnapshot.WeightRuleInfos); } else { var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value); if (weightRule == null) { throw new BusinessException("未配置计重规则"); } ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value); } var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos); forecast.VolumetricWeight = volumetricWeight; var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot); if (!weightRoundingId.HasValue) { throw new BusinessException("未配置计重进位"); } var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value); var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback( rawWeight, ranges, rulesSnapshot, weightRoundingId.Value, dbRanges); } catch (BusinessException) { throw; } catch (InvalidOperationException ex) { throw new BusinessException(ex.Message); } } private async Task> ResolveWeightRoundingRangesAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, int weightRoundingId) { var snapshotRanges = rulesSnapshot?.WeightRoundingInfos; var fromSnapshot = snapshotRanges is { Count: > 0 } && snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public)); if (fromSnapshot) { return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!); } var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId); if (rounding == null) { throw new BusinessException("未配置计重进位"); } return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId); } private static decimal ApplyRoundingWithSnapshotFallback( decimal rawWeight, IReadOnlyList ranges, SalesProductRulesPublishSnapshotDto? rulesSnapshot, int weightRoundingId, IReadOnlyList dbFallbackRanges) { var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 } && rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)); try { return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges); } catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0) { return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges); } } /// /// 查价专用:无尺寸时仅按实重进位计计费重(不算体积重)。 /// private async Task ApplyQuoteChargeableWeightFromActualOnlyAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, OrderWeight forecast) { var actualWeight = forecast.ActualWeight ?? 0; if (actualWeight <= 0) { throw new BusinessException("货物重量无效"); } forecast.VolumetricWeight = 0; forecast.Cubic = null; var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot); if (!weightRoundingId.HasValue) { throw new BusinessException("未配置计重进位"); } var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value); var dbRanges = ranges; if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 } && rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public))) { dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value); } try { forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback( actualWeight, ranges, rulesSnapshot, weightRoundingId.Value, dbRanges); } catch (InvalidOperationException ex) { throw new BusinessException(ex.Message); } } private static void EnsureBillingProductActive(SalesProduct product) { if (product.IsActive != EnableStatus.Enabled) { throw new BusinessException("订单关联的销售产品未启用,无法计费"); } } private static void ApplyMeasuredDimensions( OrderWeight weightRow, decimal length, decimal width, decimal height, decimal actualWeight) { weightRow.Length = length; weightRow.Width = width; weightRow.Height = height; weightRow.ActualWeight = actualWeight; weightRow.Cubic = ParcelBillingCalculator.CalculateCubic(length, width, height); } private static int? ResolveEffectiveWeightRuleId( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot) { if (product.WeightRuleId is > 0) { return product.WeightRuleId; } if (rulesSnapshot?.WeightRuleId is > 0) { return rulesSnapshot.WeightRuleId; } if (rulesSnapshot?.Product?.WeightRuleId is > 0) { return rulesSnapshot.Product.WeightRuleId; } return null; } private static int? ResolveEffectiveWeightRoundingId( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot) { if (product.WeightRoundingId is > 0) { return product.WeightRoundingId; } if (rulesSnapshot?.WeightRoundingId is > 0) { return rulesSnapshot.WeightRoundingId; } if (rulesSnapshot?.Product?.WeightRoundingId is > 0) { return rulesSnapshot.Product.WeightRoundingId; } return null; } private static List BuildInitialWeightRows(int orderId, OrderParcelCreateInput input) { var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value); var forecast = new OrderWeight { OrderId = orderId, Type = OrderWeightTypeCodes.Forecast, Length = input.Length, Width = input.Width, Height = input.Height, ActualWeight = input.ActualWeight, VolumetricWeight = input.VolumetricWeight ?? 0, ChargeableWeight = input.ChargeableWeight ?? input.ActualWeight, Cubic = input.Cubic ?? cubic }; return [ forecast, CloneWeightRow(orderId, OrderWeightTypeCodes.Receive, forecast), CloneWeightRow(orderId, OrderWeightTypeCodes.Ship, forecast), CloneWeightRow(orderId, OrderWeightTypeCodes.Downstream, forecast) ]; } private static OrderWeight CloneWeightRow(int orderId, string type, OrderWeight source) => new() { OrderId = orderId, Type = type, Length = source.Length, Width = source.Width, Height = source.Height, ActualWeight = source.ActualWeight, VolumetricWeight = source.VolumetricWeight, ChargeableWeight = source.ChargeableWeight, Cubic = source.Cubic }; private static void SyncWeightRows(List weights, OrderWeight forecast) { foreach (var weight in weights) { weight.Length = forecast.Length; weight.Width = forecast.Width; weight.Height = forecast.Height; weight.ActualWeight = forecast.ActualWeight; weight.VolumetricWeight = forecast.VolumetricWeight; weight.ChargeableWeight = forecast.ChargeableWeight; weight.Cubic = forecast.Cubic; } } private static Order BuildDraftOrderEntity( OrderParcelCreateInput input, SalesProduct product, string consigneeCountry, string consigneeCountryName, ProductSnapshotBinding snapshotBinding, string serviceNo, ParcelCreateContext? createContext = null) { var orderSource = string.IsNullOrWhiteSpace(createContext?.OrderSource) ? OrderSourceCodes.Manual : createContext!.OrderSource!.Trim(); return new Order { RequestId = input.RequestId, ClientNo = input.ClientNo, TrackNo = input.TrackNo, ServiceNo = serviceNo, ProductCode = input.ProductCode, ChannelCode = input.ChannelCode ?? product.ProductCode, ConsigneeCountry = consigneeCountry, ConsigneeCountryName = consigneeCountryName, CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General, ExportCustomsMode = input.ExportCustomsMode, ExportQualificationId = input.ExportQualificationId, WarehouseId = input.WarehouseId, CustomerId = input.CustomerId, OrderSource = orderSource, PickupTime = createContext?.PickupTime, CustomerSenderAddressId = createContext?.CustomerSenderAddressId, InboundMethod = string.IsNullOrWhiteSpace(createContext?.InboundMethod) ? null : createContext!.InboundMethod!.Trim(), IsCod = input.IsCod ? (byte)1 : (byte)0, CodCurrency = input.CodCurrency, CodSum = input.CodSum, DeclaredCurrency = input.DeclaredCurrency, DeclaredSum = input.DeclaredSum, Quantity = input.Quantity, IsIntercept = 0, IsRemote = 0, AdditionalServices = SerializeAdditionalServices(input.AdditionalServices), FulfillmentType = FulfillmentTypeCodes.Direct, PredeclareFailReason = string.Empty, AllocationPendingReason = string.Empty, SalesProductRulesVersion = snapshotBinding.RulesVersion, SalesProductPriceVersion = snapshotBinding.PriceVersion, LabelTemplateId = product.LabelTemplateId, OrderStatus = OrderStatusCodes.Draft }; } private async Task SaveParcelChildrenAsync( Order order, OrderParcelCreateInput input, List weights, Country receiverCountry) { if (input.Sender != null) { int? senderCountryId = null; if (!string.IsNullOrWhiteSpace(input.Sender.CountryCode)) { senderCountryId = (await GetCountryOrThrowAsync(input.Sender.CountryCode, "发件国家不存在")).Id; } var sender = await OrderRepository.InsertSenderAsync(new OrderSender { OrderId = order.Id, SenderName = input.Sender.SenderName, Company = input.Sender.Company, Country = senderCountryId, Province = input.Sender.Province, City = input.Sender.City, ZipCode = input.Sender.ZipCode, StreetAddress1 = input.Sender.StreetAddress1, StreetAddress2 = input.Sender.StreetAddress2, StreetAddress3 = input.Sender.StreetAddress3, HouseNumber = input.Sender.HouseNumber, Phone = input.Sender.Phone, TelePhone = input.Sender.TelePhone, Email = input.Sender.Email, IsDefault = 0 }); order.SenderId = sender.Id; } if (input.Receiver != null) { var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient { OrderId = order.Id, RecipientsName = input.Receiver.RecipientsName, CompanyName = input.Receiver.CompanyName, Country = receiverCountry.Id, Phone = input.Receiver.Phone, TelePhone = input.Receiver.TelePhone, ZipCode = input.Receiver.ZipCode, Province = input.Receiver.Province, City = input.Receiver.City, StreetNumber = input.Receiver.StreetNumber, StreetAddress1 = input.Receiver.StreetAddress1, StreetAddress2 = input.Receiver.StreetAddress2, StreetAddress3 = input.Receiver.StreetAddress3, Email = input.Receiver.Email, TaxType = input.Receiver.TaxType, TaxNo = input.Receiver.TaxNo, CertificateType = input.Receiver.CertificateType, CertificateNo = input.Receiver.CertificateNo, Eori = input.Receiver.Eori, Vat = input.Receiver.Vat }); order.RecipientId = recipient.Id; } if (input.Items.Count > 0) { await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration { OrderId = order.Id, CnName = x.CnName, EnName = x.EnName, Sku = x.Sku, Unit = x.Unit, UnitPrice = x.UnitPrice, UnitWeight = x.UnitWeight, Quantity = x.Quantity, Amount = x.Amount, Weight = x.Weight, HsCode = x.ExHsCode ?? x.ImHsCode, ImHsCode = x.ImHsCode, ExHsCode = x.ExHsCode, DeclareElement = x.DeclareElement, SkuImage = OmsObjectStorageExtensions.ToStorageKey(x.SkuImage), Category = x.Category, Brand = x.Brand, BrandType = x.BrandType, IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0, Distribution = x.Distribution, Material = x.Material, EnMaterial = x.EnMaterial, Purpose = x.Purpose, Attributes = x.Attributes, OriginCountry = x.OriginCountry, SalesLink = x.SalesLink, Model = x.Model, Specifications = x.Specifications, Comment = x.Comment })); } await OrderRepository.InsertWeightsAsync(weights); await OrderRepository.UpdateAsync(order); } private async Task ResolveServiceNoAsync() { for (var i = 0; i < 5; i++) { var generated = BuildParcelServiceNo(DateTime.Now, Random.Shared.Next(1000, 9999)); if (!await OrderRepository.HasServiceNoAsync(generated)) { return generated; } } throw new BusinessException("运单号生成失败"); } /// /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。 /// internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null) { if (string.IsNullOrWhiteSpace(order.ProductCode)) { throw new BusinessException("订单未绑定销售产品,无法预报"); } var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product == null || product.IsActive != EnableStatus.Enabled) { throw new BusinessException("销售产品不存在或未启用"); } try { var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); var address = await ResolveOrderBillingAddressAsync(order); if (await IsBlacklistHitAsync( product, snapshotBinding.Rules, address.CountryCode, address.Province, address.City, address.ZipCode)) { throw new BusinessException("收件地址命中黑名单区域"); } var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); if (!weights.Any(x => string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase))) { throw new BusinessException("订单缺少预报件重尺"); } order.OrderStatus = OrderStatusCodes.Draft; order.PredeclareFailReason = ForecastBillingProcessingReason; order.AllocationPendingReason = string.Empty; order.UpdateTime = DateTime.Now; 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)); } } catch (BusinessException ex) { if (string.Equals(ex.ErrorCode, OrderParcelDraftFailData.ErrorCodeValue, StringComparison.Ordinal)) { throw; } var cleanReason = StripDraftSavedMarker(ex.Message); if (!string.Equals(cleanReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) { await MarkParcelDraftFailedAsync(order, cleanReason); } throw CreateDraftSavedBusinessException(order, cleanReason); } catch (Exception ex) { var cleanReason = StripDraftSavedMarker(ex.Message); await MarkParcelDraftFailedAsync(order, cleanReason); throw CreateDraftSavedBusinessException(order, cleanReason); } } private async Task MarkParcelDraftFailedAsync(Order order, string reason) { order.OrderStatus = OrderStatusCodes.Draft; order.PredeclareFailReason = reason; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); } /// /// 入库收货:同步只写入 receive 件重尺与收货时间,状态先置待排货; /// 应收/应付/排货走异步任务,失败不回滚入库。 /// internal async Task ApplyReceiveBillingByWaybillAsync( string waybillNo, decimal? length, decimal? width, decimal? height, decimal? actualWeight) { if (string.IsNullOrWhiteSpace(waybillNo)) { throw new BusinessException("运单号不能为空"); } ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "收货"); var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim()); if (order == null) { throw new BusinessException("未找到对应订单,请检查单号"); } ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus); if (!InboundBillingInFlight.TryAdd(order.Id, 0)) { throw new BusinessException("订单入库处理中,请勿重复扫描"); } try { var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) .OrderBy(x => x.Id) .ToList(); var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive); if (receive == null) { throw new BusinessException("订单缺少收货件重尺记录"); } ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value); // 尽力算计费重,失败留给异步计费重试,不阻断入库扫码。 try { if (!string.IsNullOrWhiteSpace(order.ProductCode)) { var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product != null) { var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); } } } catch { // ignore } await OrderRepository.ReplaceWeightsAsync(order.Id, weights); order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = InboundBillingProcessingReason; order.ReceiveTime = DateTime.Now; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); // 操作日志同步落库(件重尺已写完);计费/排货仍走 MQ,不把审计塞进队列 order = await GetOrderOrThrowAsync(order.Id); var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); await OperationLogWriter.TryWriteAsync( order, OrderOperationActionCodes.Inbound, beforeSnap, afterSnap, FormatMeasuredDimensionsRemark("收货", length!.Value, width!.Value, height!.Value, actualWeight!.Value)); await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.InboundTrackCode); await EnqueueInboundBillingAsync(order); } finally { InboundBillingInFlight.TryRemove(order.Id, out _); } } /// /// 入库异步计费:重算 inbound 应收 + 尝试排货写应付;失败仅落待排货原因。 /// internal async Task ProcessInboundBillingForBatchAsync( int orderId, CancellationToken cancellationToken = default) { _ = cancellationToken; var order = await GetOrderOrThrowAsync(orderId); if (string.IsNullOrWhiteSpace(order.ProductCode)) { order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = "订单未绑定销售产品,无法入库计费"; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); return; } var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product == null) { order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = "订单关联的销售产品不存在"; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); return; } try { EnsureBillingProductActive(product); } catch (BusinessException ex) { order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = TrimPendingReason(ex.Message); order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); return; } var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) .OrderBy(x => x.Id) .ToList(); var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive); if (receive == null) { order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = "订单缺少收货件重尺记录"; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); return; } ProductSnapshotBinding snapshotBinding; try { snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); } catch (Exception ex) { order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = TrimPendingReason( ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}"); order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); return; } OrderBillingContext billing; try { billing = await ResolveOrderBillingContextAsync( order, product, receive.ChargeableWeight ?? 0, snapshotBinding); } catch (Exception ex) { order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = TrimPendingReason( ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}"); order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); return; } var (receivableOk, receivableFailReason) = await TryUpsertInboundReceivableFeesAsync(order, billing); var allocated = await TryConfirmServiceChannelAndPayableAsync( order, product, billing, OrderFeeChargingStageCodes.Inbound); try { await RefreshMarketingPayableAmountAsync(order); } catch { // 营销金额刷新失败不影响入库结果 } if (allocated && receivableOk) { order.OrderStatus = OrderStatusCodes.PendingPayment; order.AllocationPendingReason = string.Empty; } else { order.OrderStatus = OrderStatusCodes.PendingAllocation; // 排货成功会清空 AllocationPendingReason,需用应收失败原因回填 if (!receivableOk && !string.IsNullOrWhiteSpace(receivableFailReason)) { order.AllocationPendingReason = receivableFailReason; } else if (string.IsNullOrWhiteSpace(order.AllocationPendingReason)) { order.AllocationPendingReason = allocated ? "入库应收未匹配到有效卖价" : "排货未匹配到可用服务渠道或买价"; } } order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); if (allocated) { var customer = order.CustomerId.HasValue ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null); } } private async Task<(bool Ok, string? FailReason)> TryUpsertInboundReceivableFeesAsync( Order order, OrderBillingContext billing) { try { await OrderRepository.DeleteReceivableFeesAsync(order.Id); await UpsertReceivableFeesAsync( order, billing, OrderFeeChargingStageCodes.Inbound); return (true, null); } catch (BusinessException ex) { return (false, TrimPendingReason(ex.Message)); } catch (Exception ex) { return (false, TrimPendingReason($"入库应收计费失败:{ex.Message}")); } } private static string TrimPendingReason(string? message) { var text = (message ?? string.Empty).Trim(); if (text.Length <= 512) { return text; } return text[..512]; } /// /// 预报计费/排货异步任务(创单与草稿预报共用)。 /// internal async Task ProcessForecastBillingForBatchAsync( int orderId, string? payloadJson = null, int operatorUserId = 0, CancellationToken cancellationToken = default) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null) { // 创单事务尚未提交时消息可能先到。抛出让总线重试,避免当成成功 Ack 后订单永远停在处理中。 throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); } if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; } // 已预报成功则跳过(幂等) if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(order.PredeclareFailReason)) { return; } var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); int? couponInstanceId = order.CouponInstanceId; if (!string.IsNullOrWhiteSpace(payloadJson)) { try { var payload = JsonConvert.DeserializeObject(payloadJson); if (payload?.CouponInstanceId is > 0) { couponInstanceId = payload.CouponInstanceId; } } catch { // ignore bad payload } } if (string.IsNullOrWhiteSpace(order.ProductCode)) { await MarkParcelDraftFailedAsync(order, "订单未绑定销售产品,无法预报"); return; } var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product == null || product.IsActive != EnableStatus.Enabled) { await MarkParcelDraftFailedAsync(order, "销售产品不存在或未启用"); return; } ProductSnapshotBinding snapshotBinding; OrderBillingAddress address; try { snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); address = await ResolveOrderBillingAddressAsync(order); order.IsRemote = await IsRemoteHitAsync( product, snapshotBinding.Rules, address.CountryCode, address.Province, address.City, address.ZipCode) ? (byte)1 : (byte)0; } catch (Exception ex) { await MarkParcelDraftFailedAsync( order, ex is BusinessException be ? be.Message : $"预报快照解析失败:{ex.Message}"); return; } var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) .OrderBy(x => x.Id) .ToList(); var forecast = weights.FirstOrDefault(x => string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase)); if (forecast == null) { await MarkParcelDraftFailedAsync(order, "订单缺少预报件重尺"); return; } try { await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); SyncWeightRows(weights, forecast); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); } catch (Exception ex) { await MarkParcelDraftFailedAsync( order, ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}"); return; } OrderBillingContext billing; try { billing = await ResolveOrderBillingContextAsync( order, product, forecast.ChargeableWeight ?? 0, snapshotBinding); } catch (Exception ex) { await MarkParcelDraftFailedAsync( order, ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}"); return; } try { await UpsertReceivableFeesAsync( order, billing, OrderFeeChargingStageCodes.Forecast, replaceExistingReceivable: true); } catch (Exception ex) { await MarkParcelDraftFailedAsync( order, ex is BusinessException be ? be.Message : $"预报应收计费失败:{ex.Message}"); return; } // 排货失败不阻断预报成功(与原创单同步路径一致) await TryConfirmServiceChannelAndPayableAsync( order, product, billing, OrderFeeChargingStageCodes.Forecast); try { await ApplyMarketingSnapshotOnCreateAsync(order, couponInstanceId); } catch (Exception ex) { var logger = LazyServiceProvider.GetRequiredService() .CreateLogger("Oms.ForecastBilling"); logger.LogWarning(ex, "预报营销快照失败 OrderId={OrderId}", order.Id); } try { await CaptureForecastBillingSnapshotAsync(order); } catch { // ignore } order.OrderStatus = OrderStatusCodes.Predeclared; order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); await OperationLogWriter.TryWriteAsync( order, OrderOperationActionCodes.PreDeclare, beforeSnap, afterSnap, "预报计费/排货完成", operatorUserId > 0 ? operatorUserId : null, operatorName: null, operatorType: "staff", source: "job"); } private sealed class ForecastBillingJobPayload { 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); if (order == null || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; } await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); } private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; } await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId); } private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null || !string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) { return; } await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } private async Task PublishForecastTrackAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; } var customer = order.CustomerId.HasValue ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); request.AppendInitialTrackOnSuccess = true; ApplyTrackAuthorization(request); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } private async Task PublishForecastLabelAsync(int orderId, int operatorUserId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; } await ParcelCreateEventPublisher.PublishLabelGenerateAsync(order, operatorUserId); } private async Task PublishForecastAutoTriggerAsync(int orderId, int operatorUserId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; } await AutoTriggerRuntimeService.EnqueueFireAsync(orderId, AutoTriggerTiming.OrderCreate, operatorUserId); } private async Task EnqueueForecastBillingAsync( Order order, int? couponInstanceId, int operatorUserId) { var logger = LazyServiceProvider.GetRequiredService() .CreateLogger("Oms.ForecastBilling"); var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload { CouponInstanceId = couponInstanceId }); try { var publisher = LazyServiceProvider.GetRequiredService(); if (!publisher.IsEnabled) { try { await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorUserId); } catch (Exception ex) { logger.LogError( ex, "预报计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); await MarkParcelDraftFailedAsync( order, TrimPendingReason($"预报计费失败:{ex.Message}")); } return; } var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}"; await BatchJobCoordinator.CreateAndEnqueueAsync( new OrderBatchJobCreateInput { Action = OrderBatchActionCodes.ForecastBilling, OrderIds = [order.Id], RequestId = requestId, PayloadJson = payloadJson }, operatorUserId); } catch (Exception ex) { logger.LogError( ex, "预报计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); try { await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } catch { // ignore } } } private async Task EnqueueInboundBillingAsync(Order order) { var logger = LazyServiceProvider.GetRequiredService() .CreateLogger("Oms.InboundBilling"); try { var publisher = LazyServiceProvider.GetRequiredService(); if (!publisher.IsEnabled) { // 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成) try { await ProcessInboundBillingForBatchAsync(order.Id); } catch (Exception ex) { logger.LogError(ex, "入库计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); order.OrderStatus = OrderStatusCodes.PendingAllocation; order.AllocationPendingReason = TrimPendingReason($"入库计费失败:{ex.Message}"); order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); } return; } var requestId = await ResolveInboundBillingRequestIdAsync(order.Id); if (requestId == null) { logger.LogInformation( "入库计费任务已在队列中,跳过重复投递 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); return; } await BatchJobCoordinator.CreateAndEnqueueAsync( new OrderBatchJobCreateInput { Action = OrderBatchActionCodes.InboundBilling, OrderIds = [order.Id], RequestId = requestId }, CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); } catch (Exception ex) { // 投递失败不回滚入库;落待排货并记错误日志,供运营重跑入库计费 logger.LogError( ex, "入库计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); try { order.OrderStatus = OrderStatusCodes.PendingAllocation; if (string.IsNullOrWhiteSpace(order.AllocationPendingReason) || order.AllocationPendingReason == InboundBillingProcessingReason) { order.AllocationPendingReason = InboundBillingEnqueueFailReason; } order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); } catch { // ignore } } } /// /// 幂等 RequestId:进行中任务复用并跳过;已结束任务换新 ID 允许重跑。 /// 返回 null 表示已有进行中任务,无需再投递。 /// private async Task ResolveInboundBillingRequestIdAsync(int orderId) { var batchJobRepository = LazyServiceProvider.GetRequiredService(); var stableId = $"inbound-billing:{orderId}"; var existing = await batchJobRepository.GetByRequestIdAsync(stableId); if (existing != null) { if (IsActiveBatchJob(existing.Status)) { return null; } // 旧稳定 ID 已结束,后续用带时间戳 ID,避免被已完成任务挡住重跑 return $"inbound-billing:{orderId}:{DateTime.UtcNow:yyyyMMddHHmmssfff}"; } return stableId; } private static bool IsActiveBatchJob(string? status) => string.Equals(status, OrderBatchJobStatusCodes.Pending, StringComparison.OrdinalIgnoreCase) || string.Equals(status, OrderBatchJobStatusCodes.Running, StringComparison.OrdinalIgnoreCase); /// /// 出库发货后:仅覆盖 ship 件重尺、重算计费重并更新状态;不再重算应收。 /// internal async Task ApplyShipBillingByWaybillAsync( string waybillNo, decimal? length, decimal? width, decimal? height, decimal? actualWeight) { if (string.IsNullOrWhiteSpace(waybillNo)) { throw new BusinessException("运单号不能为空"); } ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "发货"); var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim()); if (order == null) { throw new BusinessException("未找到对应订单,请检查单号"); } if (string.IsNullOrWhiteSpace(order.ProductCode)) { throw new BusinessException("订单未绑定销售产品,无法出库计费"); } if (order.IsPaid == 0) { throw new BusinessException("订单未付款,禁止出库"); } if (order.OrderStatus != OrderStatusCodes.PendingShip) { if (order.OrderStatus is OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed) { throw new BusinessException("订单已出库"); } throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许出库"); } var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product == null) { throw new BusinessException("订单关联的销售产品不存在"); } EnsureBillingProductActive(product); var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) .OrderBy(x => x.Id) .ToList(); var ship = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Ship); if (ship == null) { throw new BusinessException("订单缺少发货件重尺记录"); } ApplyMeasuredDimensions(ship, length!.Value, width!.Value, height!.Value, actualWeight!.Value); var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); // 出库仅更新件重尺与状态,不再重算/覆盖应收。 var now = DateTime.Now; order.OrderStatus = OrderStatusCodes.PendingOnline; order.ShipTime = now; order.UpdateTime = now; await OrderRepository.UpdateAsync(order); order = await GetOrderOrThrowAsync(order.Id); var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true); await OperationLogWriter.TryWriteAsync( order, OrderOperationActionCodes.Outbound, beforeSnap, afterSnap, FormatMeasuredDimensionsRemark("发货", length!.Value, width!.Value, height!.Value, actualWeight!.Value)); await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.OutboundTrackCode); } private static string FormatMeasuredDimensionsRemark( string stage, decimal length, decimal width, decimal height, decimal actualWeight) { return $"{stage} 长×宽×高×重:{length:0.####}×{width:0.####}×{height:0.####}×{actualWeight:0.####}"; } private async Task BuildParcelCreateOutputAsync(Order order) { // 预报已成功时,映射/面单/本地存储失败不得回滚订单(常见:容器 /app/wwwroot 不可写)。 var logger = LazyServiceProvider.GetRequiredService() .CreateLogger("Oms.OrderParcelCreate"); var labelUrl = string.Empty; try { var mapped = (await MapOrderOutputsAsync([order])).First(); try { labelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id) ?? string.Empty; } catch (Exception ex) { logger.LogWarning( ex, "预报成功但读取系统面单 URL 失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); } if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared) { try { labelUrl = await CreateOrderPrintFileAsync(mapped); } catch (Exception ex) { logger.LogWarning( ex, "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); } } } catch (Exception ex) { logger.LogWarning( ex, "预报成功但组装创单出参失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); } return new OrderParcelCreateOutput { OrderId = order.Id, ClientNo = order.ClientNo, ServiceNo = order.ServiceNo, TrackNo = order.TrackNo, ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountryName = order.ConsigneeCountryName, LabelUrl = labelUrl, OrderStatus = order.OrderStatus, PredeclareFailReason = order.PredeclareFailReason, ForecastBillingQueued = string.Equals( order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal) }; } private IOrderParcelCreateEventPublisher ParcelCreateEventPublisher => LazyServiceProvider.GetRequiredService(); private IOrderSideEffectEventPublisher SideEffectEventPublisher => LazyServiceProvider.GetRequiredService(); /// /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 /// private async Task EnqueueSmallBagTrackUpsertAsync( Order order, Customer? customer, OrderParcelReceiverInput? receiver, bool appendInitialTrack = false) { var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); request.AppendInitialTrackOnSuccess = appendInitialTrack; ApplyTrackAuthorization(request); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request) { var authorization = LazyServiceProvider .GetRequiredService() .HttpContext? .Request .Headers .Authorization .ToString(); if (!string.IsNullOrWhiteSpace(authorization)) { request.Authorization = authorization; } } /// /// 跟踪号相对旧值有变更且新值非空时,再创建/更新一次轨迹订单。 /// private async Task EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync( Order order, string? previousTrackNo, bool appendInitialTrack = true) { var next = order.TrackNo?.Trim() ?? string.Empty; var prev = previousTrackNo?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(next) || string.Equals(next, prev, StringComparison.Ordinal)) { return; } await EnqueueSmallBagTrackUpsertAsync(order, customer: null, receiver: null, appendInitialTrack); } private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode) { if (string.IsNullOrWhiteSpace(order.ServiceNo)) { return; } var authorization = LazyServiceProvider .GetRequiredService() .HttpContext? .Request .Headers .Authorization .ToString(); await SideEffectEventPublisher.PublishLifecycleTrackAppendAsync( order.Id, order.ServiceNo, trackCode, string.IsNullOrWhiteSpace(authorization) ? null : authorization); } /// /// 按订单同步轨迹(UpsertSmallBag),返回完整请求与响应便于联调。 /// [HttpPost("/order/track-upsert-small-bag")] public async Task TrackUpsertSmallBagAsync(OrderActionInput input) { var order = await GetOrderOrThrowAsync(input.Id); if (string.IsNullOrWhiteSpace(order.ServiceNo)) { throw new BusinessException("订单运单号为空,无法同步轨迹"); } var request = await BuildSmallBagTrackUpsertRequestAsync(order); request.AppendInitialTrackOnSuccess = true; var authorization = LazyServiceProvider .GetRequiredService() .HttpContext? .Request .Headers .Authorization .ToString(); if (!string.IsNullOrWhiteSpace(authorization)) { request.Authorization = authorization; } return await SmallBagTrackOrderClient.UpsertDebugAsync(request); } private async Task BuildSmallBagTrackUpsertRequestAsync( Order order, OrderParcelReceiverInput? receiverInput = null) { return await SmallBagTrackUpsertRequestBuilder.BuildAsync(order, receiverInput); } private ISmallBagTrackUpsertRequestBuilder SmallBagTrackUpsertRequestBuilder => LazyServiceProvider.GetRequiredService(); private ISmallBagTrackOrderClient SmallBagTrackOrderClient => LazyServiceProvider.GetRequiredService(); /// /// 调试:同步调用轨迹 UpsertSmallBag,返回完整请求与响应(Swagger 可测)。 /// [HttpPost("/order/debug/track-upsert-small-bag")] public Task DebugTrackUpsertSmallBagAsync([FromBody] OrderTrackUpsertDebugInput? input) { input ??= new OrderTrackUpsertDebugInput(); var suffix = DateTime.Now.ToString("yyyyMMddHHmmss"); var request = new SmallBagTrackUpsertRequest { Client = string.IsNullOrWhiteSpace(input.Client) ? "TEST001" : input.Client.Trim(), OrderNo = string.IsNullOrWhiteSpace(input.OrderNo) ? $"LP-DEBUG-{suffix}" : input.OrderNo.Trim(), TrackNo = input.TrackNo?.Trim() ?? string.Empty, OtherNo = string.IsNullOrWhiteSpace(input.OtherNo) ? $"DEBUG-{suffix}" : input.OtherNo.Trim(), Channel = string.IsNullOrWhiteSpace(input.Channel) ? "test01" : input.Channel.Trim(), Product = string.IsNullOrWhiteSpace(input.Product) ? "test01" : input.Product.Trim(), ApiCode = input.ApiCode?.Trim() ?? string.Empty, Consignee = new SmallBagTrackConsigneeRequest { CountryCode = string.IsNullOrWhiteSpace(input.CountryCode) ? "US" : input.CountryCode.Trim(), Province = input.Province?.Trim() ?? "California", Postcode = input.Postcode?.Trim() ?? "90001", City = input.City?.Trim() ?? "Los Angeles", Email = input.Email?.Trim() ?? "debug@example.com" } }; return SmallBagTrackOrderClient.UpsertDebugAsync(request); } /// /// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。 /// private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) => OmsSessionHelper.ResolveSessionCustomerAsync( CustomerRepository, CurrentUser, requestedCustomerId); private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId) { var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees); var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees); var marketingHelper = LazyServiceProvider.GetRequiredService(); var breakdown = await marketingHelper.BuildBreakdownAsync( order.CustomerId, receivableSubtotal, baseFreightAmount, couponInstanceId, order.ConsigneeCountry); marketingHelper.ApplySnapshotToOrder(order, breakdown, couponInstanceId); if (breakdown.CouponInstanceId.HasValue && order.CustomerId.HasValue) { var couponManager = LazyServiceProvider.GetRequiredService(); var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(order.CustomerId.Value); await couponManager.LockForOrderAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id); } } /// /// 预报计费完成后固化应收/应付快照;入库重算不得覆盖。 /// private async Task CaptureForecastBillingSnapshotAsync(Order order) { var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); order.ForecastReceivableAmount = order.ReceivableSubtotal ?? fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount); order.ForecastPayableAmount = fees .Where(x => x.Type == OrderFeeTypeCodes.Payable) .Sum(x => x.Amount); } /// /// 入库等场景重算应收后,按当前应收小计刷新营销应付金额(不重新锁定优惠券)。 /// private async Task RefreshMarketingPayableAmountAsync(Order order) { var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees); var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees); var marketingHelper = LazyServiceProvider.GetRequiredService(); var breakdown = await marketingHelper.BuildBreakdownAsync( order.CustomerId, receivableSubtotal, baseFreightAmount, order.CouponInstanceId, order.ConsigneeCountry); marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId); } private static void EnsureReceivableMutable(Order order) { if (order.IsPaid != 0) { throw new BusinessException("订单已付款,不能修改应收费用"); } } private static void EnsureAdditionalServicesAllowed(SalesProduct product, List? selected) { var selectedCodes = NormalizeAdditionalServiceCodes(selected); if (selectedCodes.Count == 0) { return; } var allowed = ParseAdditionalServiceCodes(product.AdditionalServices); if (allowed.Count == 0) { throw new BusinessException("销售产品未配置附加服务,不能勾选附加服务"); } var invalid = selectedCodes .Where(code => !allowed.Contains(code)) .ToList(); if (invalid.Count > 0) { throw new BusinessException($"附加服务不在产品可选范围:{string.Join(", ", invalid)}"); } } private static string? SerializeAdditionalServices(List? codes) { var normalized = NormalizeAdditionalServiceCodes(codes); return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized); } private static List NormalizeAdditionalServiceCodes(IEnumerable? codes) => (codes ?? []) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); private static HashSet ParseAdditionalServiceCodes(string? json) { var codes = OmsDocHelper.DeserializeJson>(json) ?? []; return NormalizeAdditionalServiceCodes(codes) .ToHashSet(StringComparer.OrdinalIgnoreCase); } private static OrderRuleEvaluationContext BuildBillingConditionContext( Order order, byte? overrideIsRemote = null) { var evalOrder = overrideIsRemote.HasValue ? new Order { Id = order.Id, ProductCode = order.ProductCode, ChannelCode = order.ChannelCode, ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountryName = order.ConsigneeCountryName, CargoAttribute = order.CargoAttribute, WarehouseId = order.WarehouseId, CustomerId = order.CustomerId, DeclaredCurrency = order.DeclaredCurrency, DeclaredSum = order.DeclaredSum, Quantity = order.Quantity, IsRemote = overrideIsRemote.Value, AdditionalServices = order.AdditionalServices, IsPaid = order.IsPaid, IsCod = order.IsCod, CodCurrency = order.CodCurrency, CodSum = order.CodSum } : order; return OrderRuleEvaluationContext.FromOrderOnly(evalOrder); } }