Support multi cargo-type and warehouse combinations in price check billing. Auto-generate customer codes (W+yyMM+seq) on create/register; require email. Use order DownstreamSupplierCode as track UpsertSmallBag apiCode. Co-authored-by: Cursor <cursoragent@cursor.com>
1556 lines
58 KiB
C#
1556 lines
58 KiB
C#
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.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 async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
|
||
{
|
||
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("收件地址命中黑名单区域");
|
||
}
|
||
|
||
var serviceNo = await ResolveServiceNoAsync(input.ServiceNo);
|
||
var order = BuildDraftOrderEntity(input, product, country.CountryCode, country.ChineseName, snapshotBinding, serviceNo);
|
||
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);
|
||
|
||
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 static void NormalizeParcelCreateInput(OrderParcelCreateInput input)
|
||
{
|
||
input.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim();
|
||
input.ServiceNo = string.IsNullOrWhiteSpace(input.ServiceNo) ? null : input.ServiceNo.Trim();
|
||
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.ProductCode))
|
||
{
|
||
throw new BusinessException("产品代码不能为空");
|
||
}
|
||
|
||
if (!input.WarehouseId.HasValue || input.WarehouseId <= 0)
|
||
{
|
||
throw new BusinessException("仓库ID不能为空");
|
||
}
|
||
|
||
if (!input.CustomerId.HasValue || input.CustomerId <= 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)
|
||
{
|
||
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,
|
||
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(string? serviceNo)
|
||
{
|
||
var normalized = serviceNo?.Trim();
|
||
if (!string.IsNullOrWhiteSpace(normalized))
|
||
{
|
||
if (await OrderRepository.HasServiceNoAsync(normalized))
|
||
{
|
||
throw new BusinessException("运单号已存在");
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
for (var i = 0; i < 5; i++)
|
||
{
|
||
var generated = $"LP{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
|
||
{
|
||
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);
|
||
}
|
||
|
||
/// <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);
|
||
}
|