admin/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
2026-07-17 14:17:00 +08:00

1611 lines
60 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.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<ICustomerRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
private AutoTriggerRuntimeService AutoTriggerRuntimeService =>
LazyServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
private OrderAutoTriggerService OrderAutoTriggerService =>
LazyServiceProvider.GetRequiredService<OrderAutoTriggerService>();
private sealed class ParcelCreateContext
{
public string? OrderSource { get; init; }
public DateTime? PickupTime { get; init; }
public int? CustomerSenderAddressId { get; init; }
}
private async Task<OrderParcelCreateOutput> 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 zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
forecast.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Forecast);
var downstream = weights.First(x => x.Type == OrderWeightTypeCodes.Downstream);
await ConfirmServiceChannelAndPayableAsync(
order,
product,
snapshotBinding,
zoneKeys,
downstream.ChargeableWeight ?? forecast.ChargeableWeight ?? 0,
OrderFeeChargingStageCodes.Forecast,
customer?.CustomerGroup);
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.IsRemote = order.IsRemote;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver);
try
{
var triggerUserId = operatorUserId ?? CurrentUser.GetId<int>();
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 = "ID";
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();
}
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 (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || 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<ProductSnapshotBinding> 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<ProductSnapshotBinding> 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 UpsertReceivableFeesAsync(
Order order,
ProductSnapshotBinding snapshotBinding,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage,
bool replaceExistingReceivable = false)
{
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
chargingStage);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
{
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格");
}
if (fees.Count > 0)
{
if (replaceExistingReceivable)
{
await OrderRepository.DeleteReceivableFeesByStageAsync(order.Id, chargingStage);
}
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
}
/// <summary>
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
/// </summary>
private async Task ConfirmServiceChannelAndPayableAsync(
Order order,
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
IReadOnlyList<string> zoneKeys,
decimal chargeableWeight,
string chargingStage,
CustomerGroup? customerGroup)
{
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<AllocationResolver.ChannelCandidate>();
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,
customerGroup,
chargingStage);
if (previewFees.Count == 0)
{
continue;
}
candidates.Add(new AllocationResolver.ChannelCandidate
{
Provider = provider,
Channel = channel,
TotalPayableCost = previewFees.Sum(x => x.Amount)
});
}
if (candidates.Count == 0)
{
throw new BusinessException("排货规则未匹配到有效渠道买价");
}
var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates);
var selectedCandidate = candidates.First(x => x.Channel.Id == selectedChannel.Id);
var channelPriceBindingFinal = await ResolveChannelPriceBindingAsync(selectedChannel.Id, bindTime);
var payableFees = ParcelBillingCalculator.CalculatePayableFees(
channelPriceBindingFinal.Prices,
channelPriceBindingFinal.PriceVersion,
channelPriceBindingFinal.PriceEffectiveTime,
chargeableWeight,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customerGroup,
chargingStage);
if (payableFees.Count == 0
&& ParcelBillingCalculator.HasEnabledPayablePriceItems(channelPriceBindingFinal.Prices))
{
throw new BusinessException("渠道买价未配置或未匹配到有效价格");
}
order.ChannelCode = selectedChannel.ChannelCode;
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
order,
selectedCandidate.Provider,
selectedChannel);
if (payableFees.Count > 0)
{
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
}
}
private async Task<List<SalesProductAllocationProviderSnapshotDto>> ResolveAllocationProvidersAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot)
{
List<SalesProductAllocationProviderSnapshotDto> 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<List<int>>(provider.CustomerIds) ?? []
})).ToList();
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
}
return providers;
}
private sealed class ChannelPriceBinding
{
public List<ServiceChannelPriceListOutput> Prices { get; init; } = [];
public string PriceVersion { get; init; } = string.Empty;
public DateTime PriceEffectiveTime { get; init; }
}
private async Task<ChannelPriceBinding> ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime)
{
var priceRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync(
serviceChannelId,
PriceOperatorType,
bindTime);
if (priceRecord?.Content != null)
{
var snapshot = OmsDocHelper.DeserializeJson<ServiceChannelPricePublishSnapshotDto>(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<SalesProductRulesPublishSnapshotDto> 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<SalesProductPricePublishSnapshotDto> 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<List<string>>(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<bool> 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<bool> 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<IReadOnlyList<string>?> 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<WeightRuleInfo> 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 snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
var fromSnapshot = snapshotRanges is { Count: > 0 }
&& snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
IReadOnlyList<WeightRoundingInfo> ranges;
if (fromSnapshot)
{
ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
}
else
{
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId.Value);
if (rounding == null)
{
throw new BusinessException("未配置计重进位");
}
ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
}
try
{
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
catch (InvalidOperationException) when (fromSnapshot)
{
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
if (dbRanges.Count == 0)
{
throw;
}
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges);
}
}
catch (BusinessException)
{
throw;
}
catch (InvalidOperationException ex)
{
throw new BusinessException(ex.Message);
}
}
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<OrderWeight> 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<OrderWeight> 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,
SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion,
OrderStatus = OrderStatusCodes.Draft
};
}
private async Task SaveParcelChildrenAsync(
Order order,
OrderParcelCreateInput input,
List<OrderWeight> 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<string> ResolveServiceNoAsync()
{
for (var i = 0; i < 5; i++)
{
var generated = $"{ParcelServiceNoPrefix}{DateTime.Now:yyyyMMddHHmmss}{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);
}
/// <summary>
/// 入库收货后:仅覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并更新订单状态。
/// </summary>
internal async Task ApplyReceiveBillingByWaybillAsync(
string waybillNo,
decimal? length,
decimal? width,
decimal? height,
decimal? actualWeight)
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
throw new BusinessException("运单号不能为空");
}
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
}
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法入库计费");
}
if (order.OrderStatus is not (OrderStatusCodes.Predeclared or OrderStatusCodes.Received))
{
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许入库收货");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("订单关联的销售产品不存在");
}
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("订单缺少收货件重尺记录");
}
receive.Length = length;
receive.Width = width;
receive.Height = height;
receive.ActualWeight = actualWeight;
receive.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
recipient?.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
receive.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Inbound,
replaceExistingReceivable: true);
await ConfirmServiceChannelAndPayableAsync(
order,
product,
snapshotBinding,
zoneKeys,
receive.ChargeableWeight ?? 0,
OrderFeeChargingStageCodes.Inbound,
customer?.CustomerGroup);
order.OrderStatus = OrderStatusCodes.PendingPayment;
order.ReceiveTime = DateTime.Now;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
/// <summary>
/// 出库发货后:仅覆盖 ship 件重尺、重算计费重,写入 outbound 阶段应收,并更新订单状态。
/// </summary>
internal async Task ApplyShipBillingByWaybillAsync(
string waybillNo,
decimal? length,
decimal? width,
decimal? height,
decimal? actualWeight)
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
throw new BusinessException("运单号不能为空");
}
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
throw new BusinessException("发货实重与长宽高不能为空且须大于 0");
}
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("订单关联的销售产品不存在");
}
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("订单缺少发货件重尺记录");
}
ship.Length = length;
ship.Width = width;
ship.Height = height;
ship.ActualWeight = actualWeight;
ship.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
recipient?.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
ship.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Outbound,
replaceExistingReceivable: true);
var now = DateTime.Now;
order.OrderStatus = OrderStatusCodes.PendingOnline;
order.ShipTime = now;
order.UpdateTime = now;
await OrderRepository.UpdateAsync(order);
}
private async Task<OrderParcelCreateOutput> 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 = order.OrderStatus == OrderStatusCodes.Predeclared
? await CreateOrderPrintFileAsync(mapped)
: string.Empty
};
}
private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderNotifier>();
/// <summary>
/// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。
/// </summary>
private async Task EnqueueSmallBagTrackUpsertAsync(
Order order,
Customer? customer,
OrderParcelReceiverInput? receiver)
{
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{
request.Authorization = authorization;
}
SmallBagTrackOrderNotifier.Enqueue(request, order.Id, customer?.Id);
}
/// <summary>
/// 按订单同步轨迹(UpsertSmallBag),返回完整请求与响应便于联调。
/// </summary>
[HttpPost("/order/track-upsert-small-bag")]
public async Task<SmallBagTrackUpsertResult> 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<SmallBagTrackUpsertRequest> BuildSmallBagTrackUpsertRequestAsync(
Order order,
OrderParcelReceiverInput? receiverInput = null)
{
Customer? customer = null;
if (order.CustomerId.HasValue)
{
customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
}
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
var apiCode = order.DownstreamSupplierCode?.Trim() ?? string.Empty;
return new SmallBagTrackUpsertRequest
{
Client = customer?.CustomerCode?.Trim() ?? string.Empty,
OrderNo = order.ServiceNo,
TrackNo = order.TrackNo?.Trim() ?? string.Empty,
OtherNo = order.ClientNo?.Trim() ?? string.Empty,
Channel = order.ChannelCode?.Trim() ?? string.Empty,
Product = order.ProductCode?.Trim() ?? string.Empty,
ApiCode = apiCode,
Consignee = new SmallBagTrackConsigneeRequest
{
CountryCode = receiverInput?.CountryCode?.Trim() ?? order.ConsigneeCountry?.Trim() ?? string.Empty,
Province = receiverInput?.Province?.Trim() ?? recipient?.Province?.Trim() ?? string.Empty,
Postcode = receiverInput?.ZipCode?.Trim() ?? recipient?.ZipCode?.Trim() ?? string.Empty,
City = receiverInput?.City?.Trim() ?? recipient?.City?.Trim() ?? string.Empty,
Email = receiverInput?.Email?.Trim() ?? recipient?.Email?.Trim() ?? string.Empty
}
};
}
private ISmallBagTrackOrderClient SmallBagTrackOrderClient =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();
/// <summary>
/// 调试:同步调用轨迹 UpsertSmallBag,返回完整请求与响应(Swagger 可测)。
/// </summary>
[HttpPost("/order/debug/track-upsert-small-bag")]
public Task<SmallBagTrackUpsertResult> 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);
}
/// <summary>
/// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。
/// </summary>
private Task<Customer> ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) =>
OmsSessionHelper.ResolveSessionCustomerAsync(
CustomerRepository,
CurrentUser,
requestedCustomerId);
}