using Fake.Application; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; 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.Shared; 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.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; 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 ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService(); private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService(); private IKpiAreaRepository KpiAreaRepository => 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; } } 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); 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("销售产品不存在或未启用"); } 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 order = BuildDraftOrderEntity( input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo, createContext); order = await OrderRepository.InsertAsync(order); var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); try { order.IsRemote = await IsRemoteHitAsync( product, snapshotBinding.Rules, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode) ? (byte)1 : (byte)0; var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); SyncWeightRows(weights, forecast); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId); var billing = await ResolveBillingContextAsync( product, snapshotBinding, new OrderBillingAddress( country.CountryCode, input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode), forecast.ChargeableWeight ?? 0, order.CargoAttribute ?? CargoAttributeCodes.General, order.WarehouseId, customer?.CustomerGroup); await UpsertReceivableFeesAsync( order, billing, OrderFeeChargingStageCodes.Forecast); // 创建订单不对客户暴露排货/买价:渠道与应付在入库等环节由运营侧处理。 order.OrderStatus = OrderStatusCodes.Predeclared; order.PredeclareFailReason = string.Empty; order.AllocationPendingReason = string.Empty; order.IsRemote = order.IsRemote; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver, appendInitialTrack: true); await EnqueueGenerateLabelPdfAsync(order); try { var triggerUserId = operatorUserId ?? CurrentUser.GetId(); await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId); } catch { // 自动触发失败不影响预报主流程 } return await BuildParcelCreateOutputAsync(order); } catch (BusinessException ex) { await MarkParcelDraftFailedAsync(order, ex.Message); throw; } catch (Exception ex) { await MarkParcelDraftFailedAsync(order, ex.Message); throw new BusinessException(ex.Message); } } 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("出口报关方式不合法"); } 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("客户不存在或状态不可用"); } } 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)"); } return 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 }; } 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; } return new ProductSnapshotBinding { RulesVersion = rulesVersion, PriceVersion = priceVersion, RulesEffectiveTime = rulesEffectiveTime, PriceEffectiveTime = priceEffectiveTime, Rules = rulesSnapshot, Price = priceSnapshot }; } 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 ResolveCustomerGroupAsync(int? customerId) { if (!customerId.HasValue || customerId.Value <= 0) { return null; } var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value); return customer?.CustomerGroup; } /// /// 解析算价上下文(快照 + 分区 + 仓库 + 客户组)。供下单前或仅有地址时使用。 /// private async Task ResolveBillingContextAsync( SalesProduct product, ProductSnapshotBinding snapshotBinding, OrderBillingAddress address, decimal chargeableWeight, string cargoAttribute, int? warehouseId, CustomerGroup? customerGroup = 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, CustomerGroup = customerGroup, 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 customerGroup = await ResolveCustomerGroupAsync(order.CustomerId); var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); return new OrderBillingContext { SnapshotBinding = binding, ZoneKeys = zoneKeys, ChargeableWeight = chargeableWeight, CargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General, CustomerGroup = customerGroup, 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.CustomerGroup, chargingStage, replaceExistingReceivable, billing.WarehouseId, billing.WarehouseName); } private async Task UpsertReceivableFeesAsync( Order order, ProductSnapshotBinding snapshotBinding, decimal chargeableWeight, IReadOnlyList zoneKeys, string cargoAttribute, CustomerGroup? customerGroup, string chargingStage, bool replaceExistingReceivable = false, int? warehouseId = null, string? warehouseName = null) { if (chargeableWeight <= 0) { throw new BusinessException("计费重无效,无法计算应收费用"); } var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshotBinding.Price, snapshotBinding.PriceVersion, snapshotBinding.PriceEffectiveTime, chargeableWeight, zoneKeys, cargoAttribute, customerGroup, chargingStage, warehouseId ?? order.WarehouseId, warehouseName); if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price)) { var detail = ParcelBillingCalculator.ExplainQuotePriceMiss( snapshotBinding.Price, chargeableWeight, zoneKeys, cargoAttribute, customerGroup, warehouseId ?? order.WarehouseId, warehouseName); throw new BusinessException($"产品卖价未匹配到有效价格:{detail}"); } if (fees.Count > 0) { if (replaceExistingReceivable) { await OrderRepository.DeleteReceivableFeesByStageAsync(order.Id, chargingStage); } await OrderRepository.UpsertFeesAsync(order.Id, fees); } } /// /// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。 /// 仅用于入库、重算应付等内部环节;创建订单时不调用。 /// private async Task ConfirmServiceChannelAndPayableAsync( Order order, SalesProduct product, ProductSnapshotBinding snapshotBinding, IReadOnlyList zoneKeys, decimal chargeableWeight, string chargingStage, CustomerGroup? _) { if (chargeableWeight <= 0) { throw new BusinessException("计费重无效,无法计算应付费用"); } var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId); if (filtered.Count == 0) { throw new BusinessException("排货规则未匹配到可用服务渠道"); } var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime; var scheme = snapshotBinding.Rules.Product?.Scheme ?? product.Scheme ?? AllocationSchemeCodes.CostLowest; var candidates = new List(); foreach (var provider in filtered) { var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId!.Value); if (channel == null || channel.IsActive != EnableStatus.Enabled) { continue; } var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime); var previewFees = ParcelBillingCalculator.CalculatePayableFees( channelPriceBinding.Prices, channelPriceBinding.PriceVersion, channelPriceBinding.PriceEffectiveTime, chargeableWeight, zoneKeys, order.CargoAttribute ?? CargoAttributeCodes.General, chargingStage, order.WarehouseId, warehouseName); if (previewFees.Count == 0) { continue; } candidates.Add(new AllocationResolver.ChannelCandidate { Provider = provider, Channel = channel, TotalPayableCost = previewFees.Sum(x => x.Amount), PayableFees = previewFees }); } if (candidates.Count == 0) { throw new BusinessException("排货规则未匹配到有效渠道买价"); } var selectedCandidate = AllocationResolver.SelectCandidate(scheme, candidates); var payableFees = selectedCandidate.PayableFees.ToList(); if (payableFees.Count == 0) { throw new BusinessException("渠道买价未配置或未匹配到有效价格"); } order.ChannelCode = selectedCandidate.Channel.ChannelCode; AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder( order, selectedCandidate.Provider, selectedCandidate.Channel); await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage); await OrderRepository.UpsertFeesAsync(order.Id, payableFees); order.AllocationPendingReason = string.Empty; } private Task ConfirmServiceChannelAndPayableAsync( Order order, SalesProduct product, OrderBillingContext billing, string chargingStage) => ConfirmServiceChannelAndPayableAsync( order, product, billing.SnapshotBinding, billing.ZoneKeys, billing.ChargeableWeight, chargingStage, billing.CustomerGroup); /// /// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。 /// private async Task TryConfirmServiceChannelAndPayableAsync( Order order, SalesProduct product, OrderBillingContext billing, string chargingStage) { try { await ConfirmServiceChannelAndPayableAsync(order, product, billing, chargingStage); 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) ?? [] })).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; } } private async Task ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime) { var priceRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( serviceChannelId, PriceOperatorType, bindTime); if (priceRecord?.Content != null) { var snapshot = OmsDocHelper.DeserializeJson(priceRecord.Content); if (snapshot?.Prices.Count > 0) { return new ChannelPriceBinding { Prices = snapshot.Prices, PriceVersion = priceRecord.Version, PriceEffectiveTime = priceRecord.PublishTime }; } } return new ChannelPriceBinding { Prices = await BuildChannelPriceOutputsAsync(serviceChannelId), PriceVersion = $"LIVE-{serviceChannelId}", PriceEffectiveTime = bindTime }; } 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.ApplyRulesToSnapshotAsync( snapshot, product, KpiAreaRepository, WeightRuleRepository, WeightRoundingRepository); 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.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository); 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 async Task IsBlacklistHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, string? province, string? city, string? zipCode) { var areaId = ResolveEffectiveBlacklistAreaId(product, rulesSnapshot); if (!areaId.HasValue) { return false; } if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 }) { return RulesSnapshotHelper.IsAreaHitFromSnapshot( rulesSnapshot.BlacklistAreaInfos, countryCode, province, city, zipCode); } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( rule, countryCode, province, city, zipCode)); } private async Task IsRemoteHitAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, string countryCode, string? province, string? city, string? zipCode) { var areaId = ResolveEffectiveRemoteAreaId(product, rulesSnapshot); if (!areaId.HasValue) { return false; } if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 }) { return RulesSnapshotHelper.IsAreaHitFromSnapshot( rulesSnapshot.RemoteAreaInfos, countryCode, province, city, zipCode); } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(areaId.Value); return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched( rule, countryCode, province, city, zipCode)); } 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 async Task?> ResolvePriceZoneKeysAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, SalesProductPricePublishSnapshotDto? priceSnapshot, string countryCode, string? province, string? city, string? zipCode) { if (priceSnapshot?.PriceAreaInfos is { Count: > 0 }) { return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( priceSnapshot, countryCode, province, city, zipCode); } var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot); if (!priceAreaId.HasValue) { return null; } var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value); if (area == null) { return null; } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value); var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched( rule, countryCode, province, city, zipCode)); if (matched == null) { return null; } var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area); return keys.Count > 0 ? keys : 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 async Task ApplyStageReceivableBillingAsync( Order order, SalesProduct product, OrderWeight weightRow, ProductSnapshotBinding snapshotBinding, string chargingStage) { var billing = await ResolveOrderBillingContextAsync( order, product, weightRow.ChargeableWeight ?? 0, snapshotBinding); await UpsertReceivableFeesAsync( order, billing, chargingStage, replaceExistingReceivable: true); } 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, WarehouseId = input.WarehouseId, CustomerId = input.CustomerId, OrderSource = orderSource, PickupTime = createContext?.PickupTime, CustomerSenderAddressId = createContext?.CustomerSenderAddressId, 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, PredeclareFailReason = string.Empty, AllocationPendingReason = string.Empty, SalesProductRulesVersion = snapshotBinding.RulesVersion, SalesProductPriceVersion = snapshotBinding.PriceVersion, LabelTemplateId = product.LabelTemplateId, LabelGenerateStatus = LabelGenerateStatusCodes.Pending, 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, SkuImage = 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("运单号生成失败"); } private async Task MarkParcelDraftFailedAsync(Order order, string reason) { order.OrderStatus = OrderStatusCodes.Draft; order.PredeclareFailReason = reason; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); } /// /// 入库收货后:仅覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并更新订单状态。 /// 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("未找到对应订单,请检查单号"); } if (string.IsNullOrWhiteSpace(order.ProductCode)) { throw new BusinessException("订单未绑定销售产品,无法入库计费"); } ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus); var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product == null) { throw new BusinessException("订单关联的销售产品不存在"); } EnsureBillingProductActive(product); 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); var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); var billing = await ResolveOrderBillingContextAsync( order, product, receive.ChargeableWeight ?? 0, snapshotBinding); await UpsertReceivableFeesAsync( order, billing, OrderFeeChargingStageCodes.Inbound, replaceExistingReceivable: true); var allocated = await TryConfirmServiceChannelAndPayableAsync( order, product, billing, OrderFeeChargingStageCodes.Inbound); order.OrderStatus = allocated ? OrderStatusCodes.PendingPayment : OrderStatusCodes.PendingAllocation; order.ReceiveTime = DateTime.Now; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode); if (allocated) { var customer = order.CustomerId.HasValue ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null); } } /// /// 出库发货后:仅覆盖 ship 件重尺、重算计费重,写入 outbound 阶段应收,并更新订单状态。 /// 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 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); await ApplyStageReceivableBillingAsync( order, product, ship, snapshotBinding, OrderFeeChargingStageCodes.Outbound); var now = DateTime.Now; order.OrderStatus = OrderStatusCodes.PendingOnline; order.ShipTime = now; order.UpdateTime = now; await OrderRepository.UpdateAsync(order); EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.OutboundTrackCode); } private async Task BuildParcelCreateOutputAsync(Order order) { var mapped = (await MapOrderOutputsAsync([order])).First(); return new OrderParcelCreateOutput { OrderId = order.Id, ClientNo = order.ClientNo, ServiceNo = order.ServiceNo, TrackNo = order.TrackNo, ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountryName = order.ConsigneeCountryName, LabelUrl = !string.IsNullOrWhiteSpace(order.LabelUrl) ? order.LabelUrl! : order.OrderStatus == OrderStatusCodes.Predeclared ? await CreateOrderPrintFileAsync(mapped) : string.Empty }; } private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier => LazyServiceProvider.GetRequiredService(); private async Task EnqueueGenerateLabelPdfAsync(Order order) { try { var publisher = LazyServiceProvider.GetRequiredService(); if (!publisher.IsEnabled) { // 本地未开队列:同步生成,避免 Pending 悬挂 var mapped = (await MapOrderOutputsAsync([order])).First(); await LabelPdfGenerationService.GenerateAndPersistAsync(order, mapped); return; } await BatchJobCoordinator.CreateAndEnqueueAsync( new OrderBatchJobCreateInput { Action = OrderBatchActionCodes.GenerateLabelPdf, OrderIds = [order.Id], RequestId = $"label-pdf:{order.Id}:{Guid.NewGuid():N}" }, CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); } catch (Exception) { // 投递失败不阻断创单;打印时会补生成 } } /// /// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。 /// private async Task EnqueueSmallBagTrackUpsertAsync( Order order, Customer? customer, OrderParcelReceiverInput? receiver, bool appendInitialTrack = false) { var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); request.AppendInitialTrackOnSuccess = appendInitialTrack; var authorization = LazyServiceProvider .GetRequiredService() .HttpContext? .Request .Headers .Authorization .ToString(); if (!string.IsNullOrWhiteSpace(authorization)) { request.Authorization = authorization; } SmallBagTrackOrderNotifier.Enqueue(request, order.Id, customer?.Id); } private void EnqueueLifecycleTrackAppend(Order order, string trackCode) { if (string.IsNullOrWhiteSpace(order.ServiceNo)) { return; } var authorization = LazyServiceProvider .GetRequiredService() .HttpContext? .Request .Headers .Authorization .ToString(); SmallBagTrackOrderNotifier.EnqueueLifecycleTrackAppend( order.ServiceNo, trackCode, order.Id, 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); 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); }