admin/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
zzh 87abcecb65
All checks were successful
Build and Deploy / deploy (push) Successful in 1m45s
feat: 对账件重尺导入与应收只留一套
新增 downstream 重量导入并锁定渠道重算应付;重算应收清空全部阶段;费用软删与查询过滤,避免预报入库叠算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 16:57:28 +08:00

2890 lines
108 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 System.Collections.Concurrent;
using Fake.Application;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Integrations.SmallBagTrack;
using Oms.Application.Labeling;
using Oms.Application.Messaging;
using Oms.Application.Shared;
using Oms.Application.Services.Orders.Batch;
using Oms.Application.Services.Orders.Issues;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private const string PriceOperatorType = "price";
private const string RulesOperatorType = "rules";
private const int ParcelBatchMaxCount = 2000;
private const string InboundBillingProcessingReason = "入库后计费/排货处理中";
private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费";
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IMemberLevelRepository MemberLevelRepository => LazyServiceProvider.GetRequiredService<IMemberLevelRepository>();
private Task<string?> ResolvePriceTierKeyAsync(int? customerId) =>
MarketingCustomerHelper.ResolvePriceTierKeyAsync(CustomerRepository, MemberLevelRepository, customerId);
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; }
public string? InboundMethod { 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("销售产品不存在或未启用");
}
EnsureAdditionalServicesAllowed(product, input.AdditionalServices);
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
var country = await GetCountryOrThrowAsync(input.Receiver!.CountryCode);
if (await IsBlacklistHitAsync(
product,
snapshotBinding.Rules,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode))
{
throw new BusinessException("收件地址命中黑名单区域");
}
await OrderAutoTriggerService.EvaluatePreCommitAsync(
input,
sessionCustomer,
country.CountryCode,
country.ChineseName);
var serviceNo = await ResolveServiceNoAsync();
Order? created = null;
try
{
var order = BuildDraftOrderEntity(
input,
product,
country.CountryCode,
country.ChineseName,
snapshotBinding,
serviceNo,
createContext);
order = await OrderRepository.InsertAsync(order);
created = order;
var weights = BuildInitialWeightRows(order.Id, input);
await SaveParcelChildrenAsync(order, input, weights, country);
// 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。
if (input.SaveAsDraft)
{
await TryUpsertCustoms9610Async(order);
return await BuildParcelCreateOutputAsync(order);
}
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);
await TryUpsertCustoms9610Async(order);
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId);
var billing = await ResolveBillingContextAsync(
product,
snapshotBinding,
new OrderBillingAddress(
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode),
forecast.ChargeableWeight ?? 0,
order.CargoAttribute ?? CargoAttributeCodes.General,
order.WarehouseId,
priceTierKey);
await UpsertReceivableFeesAsync(
order,
billing,
OrderFeeChargingStageCodes.Forecast);
// 预报阶段尝试排货写应付;失败不阻断创建(仅记录 AllocationPendingReason)。
await TryConfirmServiceChannelAndPayableAsync(
order,
product,
billing,
OrderFeeChargingStageCodes.Forecast);
await ApplyMarketingSnapshotOnCreateAsync(order, input.CouponInstanceId);
await CaptureForecastBillingSnapshotAsync(order);
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver, appendInitialTrack: true);
await EnqueueGenerateLabelPdfAsync(order);
try
{
var triggerUserId = operatorUserId ?? CurrentUser.GetId<int>();
await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId);
}
catch
{
// 自动触发失败不影响预报主流程
}
return await BuildParcelCreateOutputAsync(order);
}
catch (Exception ex)
{
// 创建失败不落库草稿:回滚已插入订单,由前端让客户选择继续修改或主动保存草稿。
if (created != null)
{
try
{
await RollbackFailedParcelCreateAsync(created);
}
catch
{
// 回滚失败不覆盖原始业务错误;半成品订单需运维清理
}
}
var cleanReason = StripDraftSavedMarker(ex.Message);
if (string.IsNullOrWhiteSpace(cleanReason))
{
cleanReason = "预报未完成";
}
throw new BusinessException(cleanReason);
}
}
/// <summary>
/// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。
/// </summary>
private async Task RollbackFailedParcelCreateAsync(Order order)
{
try
{
var writer = LazyServiceProvider.GetRequiredService<Customs9610OrderWriter>();
// 强制清掉可能已写入的 pending 9610(ExportCustomsMode 临时置空触发删除逻辑)
var mode = order.ExportCustomsMode;
order.ExportCustomsMode = null;
await writer.UpsertFromOrderAsync(order);
order.ExportCustomsMode = mode;
}
catch
{
// ignore
}
order.OrderStatus = OrderStatusCodes.Deleted;
order.PredeclareFailReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
await OrderRepository.DeleteAsync(order);
}
private async Task TryUpsertCustoms9610Async(Order order)
{
try
{
await LazyServiceProvider.GetRequiredService<Customs9610OrderWriter>()
.UpsertFromOrderAsync(order);
}
catch
{
// 9610 同步失败不阻断运单创建
}
}
/// <summary>
/// 已有草稿重新预报失败时,在业务文案末尾附加机器可读标记,供前端区分。
/// 格式:原因 [[omsDraft:{"draftSaved":true,"orderId":1,"serviceNo":"IDE..."}]]
/// 同时写入 Exception.Data / ErrorCode,由 OmsException2ResponseConverter 输出到响应 Details。
/// 注意:parcel/create 失败会回滚订单,不会走此标记;仅 CompleteDraftPredeclare 使用。
/// </summary>
private const string DraftSavedMarkerPrefix = " [[omsDraft:";
private const string DraftSavedMarkerSuffix = "]]";
private static string StripDraftSavedMarker(string? message)
{
if (string.IsNullOrEmpty(message))
{
return string.Empty;
}
var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
return idx < 0 ? message : message[..idx].TrimEnd();
}
private static string WrapDraftSavedFail(Order order, string reason)
{
var clean = StripDraftSavedMarker(reason);
if (string.IsNullOrWhiteSpace(clean))
{
clean = "预报未完成";
}
var payload = global::System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object>
{
["draftSaved"] = true,
["orderId"] = order.Id,
["serviceNo"] = order.ServiceNo ?? string.Empty,
["reason"] = clean,
["errorCode"] = OrderParcelDraftFailData.ErrorCodeValue,
});
return $"{clean}{DraftSavedMarkerPrefix}{payload}{DraftSavedMarkerSuffix}";
}
/// <summary>
/// 抛出「草稿已落库」业务异常:ErrorCode + Data 结构化载荷,msg 仍带兼容标记。
/// </summary>
private static BusinessException CreateDraftSavedBusinessException(Order order, string reason)
{
var clean = StripDraftSavedMarker(reason);
if (string.IsNullOrWhiteSpace(clean))
{
clean = "预报未完成";
}
var failData = new OrderParcelDraftFailData
{
DraftSaved = true,
OrderId = order.Id,
ServiceNo = order.ServiceNo ?? string.Empty,
Reason = clean,
ErrorCode = OrderParcelDraftFailData.ErrorCodeValue,
};
var ex = new BusinessException(WrapDraftSavedFail(order, clean));
ex.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
ex.Data["draftSaved"] = true;
ex.Data["orderId"] = order.Id;
ex.Data["serviceNo"] = order.ServiceNo ?? string.Empty;
ex.Data[OrderParcelDraftFailData.DataKey] =
global::System.Text.Json.JsonSerializer.Serialize(failData);
return ex;
}
private const string ParcelServiceNoPrefix = "IDE";
/// <summary>
/// 自动生成运单号:IDE + yyMMddHHmmss + 4 位随机数(如 IDE2607221859123456)。
/// </summary>
private static string BuildParcelServiceNo(DateTime now, int randomSuffix) =>
$"{ParcelServiceNoPrefix}{now:yyMMddHHmmss}{randomSuffix}";
private static void NormalizeParcelCreateInput(OrderParcelCreateInput input)
{
input.TrackNo = null;
input.ServiceNo = null;
input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim();
input.RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim();
if (!string.IsNullOrWhiteSpace(input.CargoAttribute))
{
input.CargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoAttribute.Trim());
}
}
private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
{
if (string.IsNullOrWhiteSpace(input.ClientNo))
{
throw new BusinessException("客户单号不能为空");
}
if (!input.CustomerId.HasValue || input.CustomerId <= 0)
{
throw new BusinessException("客户ID不能为空");
}
var duplicatedClientNo = await OrderRepository.GetByCustomerAndClientNoAsync(
input.CustomerId.Value,
input.ClientNo);
if (duplicatedClientNo != null)
{
throw new BusinessException("客户单号已存在");
}
if (string.IsNullOrWhiteSpace(input.ProductCode))
{
throw new BusinessException("产品代码不能为空");
}
if (!input.WarehouseId.HasValue || input.WarehouseId <= 0)
{
throw new BusinessException("仓库ID不能为空");
}
if (!ParcelBillingValidators.HasFullDimensions(input.Length, input.Width, input.Height)
|| !input.ActualWeight.HasValue || input.ActualWeight <= 0)
{
throw new BusinessException("件重尺必须大于0");
}
if (string.IsNullOrWhiteSpace(input.CargoAttribute)
|| input.CargoAttribute is not (CargoAttributeCodes.General or CargoAttributeCodes.Electronics or CargoAttributeCodes.Sensitive))
{
throw new BusinessException("货物属性不合法");
}
if (!string.IsNullOrWhiteSpace(input.ExportCustomsMode)
&& !ExportCustomsModeCodes.All.Contains(input.ExportCustomsMode))
{
throw new BusinessException("出口报关方式不合法");
}
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
{
throw new BusinessException("申报币种不能为空");
}
if (!input.Quantity.HasValue || input.Quantity <= 0)
{
throw new BusinessException("包裹数量不能为空");
}
if (input.Receiver == null
|| string.IsNullOrWhiteSpace(input.Receiver.RecipientsName)
|| string.IsNullOrWhiteSpace(input.Receiver.CountryCode)
|| string.IsNullOrWhiteSpace(input.Receiver.ZipCode)
|| string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1))
{
throw new BusinessException("收件人信息不完整");
}
if (input.Sender == null || string.IsNullOrWhiteSpace(input.Sender.SenderName))
{
throw new BusinessException("发件人姓名不能为空");
}
if (input.Items.Count == 0)
{
throw new BusinessException("申报明细不能为空");
}
foreach (var item in input.Items)
{
if (string.IsNullOrWhiteSpace(item.EnName)
|| !item.UnitPrice.HasValue
|| !item.UnitWeight.HasValue
|| !item.Quantity.HasValue
|| item.Quantity <= 0)
{
throw new BusinessException("申报明细必填字段不完整");
}
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value);
if (warehouse == null)
{
throw new BusinessException("仓库不存在");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value);
if (customer == null || customer.Status != CustomerStatus.Normal)
{
throw new BusinessException("客户不存在或状态不可用");
}
CustomerContactHelper.EnsureContactComplete(customer);
}
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<string?> ResolveWarehouseNameAsync(int? warehouseId)
{
if (!warehouseId.HasValue || warehouseId.Value <= 0)
{
return null;
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
return warehouse?.WarehouseName;
}
private async Task<OrderBillingAddress> ResolveOrderBillingAddressAsync(Order order)
{
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
return new OrderBillingAddress(
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
recipient?.ZipCode);
}
private async Task<IReadOnlyList<string>> ResolveZoneKeysOrThrowAsync(
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
OrderBillingAddress address)
{
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
address.CountryCode,
address.Province,
address.City,
address.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
return zoneKeys;
}
private async Task<OrderBillingContext> ResolveBillingContextAsync(
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
OrderBillingAddress address,
decimal chargeableWeight,
string cargoAttribute,
int? warehouseId,
string? priceTierKey = null)
{
if (chargeableWeight <= 0)
{
throw new BusinessException("计费重无效,无法计费");
}
var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, snapshotBinding, address);
var warehouseName = await ResolveWarehouseNameAsync(warehouseId);
return new OrderBillingContext
{
SnapshotBinding = snapshotBinding,
ZoneKeys = zoneKeys,
ChargeableWeight = chargeableWeight,
CargoAttribute = cargoAttribute,
PriceTierKey = priceTierKey,
WarehouseId = warehouseId,
WarehouseName = warehouseName
};
}
/// <summary>
/// 解析已有订单的算价上下文(绑定订单快照版本)。
/// </summary>
private async Task<OrderBillingContext> ResolveOrderBillingContextAsync(
Order order,
SalesProduct product,
decimal chargeableWeight,
ProductSnapshotBinding? snapshotBinding = null)
{
if (chargeableWeight <= 0)
{
throw new BusinessException("计费重无效,无法计费");
}
var binding = snapshotBinding ?? await ResolveSnapshotBindingFromOrderAsync(product, order);
var address = await ResolveOrderBillingAddressAsync(order);
var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, binding, address);
var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId);
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
return new OrderBillingContext
{
SnapshotBinding = binding,
ZoneKeys = zoneKeys,
ChargeableWeight = chargeableWeight,
CargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General,
PriceTierKey = priceTierKey,
WarehouseId = order.WarehouseId,
WarehouseName = warehouseName
};
}
private async Task UpsertReceivableFeesAsync(
Order order,
OrderBillingContext billing,
string chargingStage,
bool replaceExistingReceivable = false)
{
await UpsertReceivableFeesAsync(
order,
billing.SnapshotBinding,
billing.ChargeableWeight,
billing.ZoneKeys,
billing.CargoAttribute,
billing.PriceTierKey,
chargingStage,
replaceExistingReceivable,
billing.WarehouseId,
billing.WarehouseName);
}
private async Task UpsertReceivableFeesAsync(
Order order,
ProductSnapshotBinding snapshotBinding,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
string? priceTierKey,
string chargingStage,
bool replaceExistingReceivable = false,
int? warehouseId = null,
string? warehouseName = null)
{
var conditionContext = BuildBillingConditionContext(order);
var remoteAreaId = order.IsRemote > 0
? (snapshotBinding.Rules?.RemoteAreaId
?? snapshotBinding.Rules?.Product?.RemoteAreaId)
: null;
var remoteZoneKeys = ParcelBillingCalculator.BuildRemoteZoneKeys(
remoteAreaId is > 0 ? remoteAreaId : null,
snapshotBinding.Rules?.RemoteAreaName);
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
chargingStage,
warehouseId ?? order.WarehouseId,
warehouseName,
conditionContext,
order.DeclaredSum,
order.DeclaredCurrency,
remoteZoneKeys);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
{
if (chargeableWeight <= 0)
{
throw new BusinessException("计费重无效,无法计算应收费用");
}
var detail = ParcelBillingCalculator.ExplainQuotePriceMiss(
snapshotBinding.Price,
chargeableWeight,
zoneKeys,
cargoAttribute,
priceTierKey,
warehouseId ?? order.WarehouseId,
warehouseName);
throw new BusinessException($"产品卖价未匹配到有效价格:{detail}");
}
if (fees.Count > 0)
{
if (replaceExistingReceivable)
{
// 有效应收只保留一套:清空全部阶段后再写入当前阶段,避免预报+入库叠算。
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
}
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
}
/// <summary>
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
/// 仅用于入库、重算应付等内部环节;创建订单时不调用。
/// </summary>
private async Task ConfirmServiceChannelAndPayableAsync(
Order order,
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
IReadOnlyList<string> zoneKeys,
decimal chargeableWeight,
string chargingStage,
string? _,
string? schemeOverride = null,
int? forceServiceChannelId = null)
{
var evaluation = await EvaluateAllocationAsync(
order,
product,
snapshotBinding,
zoneKeys,
chargeableWeight,
chargingStage,
schemeOverride,
forceServiceChannelId);
ApplyAllocationTraceToOrder(order, evaluation);
if (!string.IsNullOrWhiteSpace(evaluation.FailReason) || evaluation.Selected == null)
{
throw new BusinessException(evaluation.FailReason ?? "排货规则未匹配到可用服务渠道");
}
var selectedCandidate = evaluation.Selected;
var payableFees = selectedCandidate.PayableFees.ToList();
if (payableFees.Count == 0)
{
evaluation.FailReason = "渠道买价未配置或未匹配到有效价格";
ApplyAllocationTraceToOrder(order, evaluation);
throw new BusinessException(evaluation.FailReason);
}
var previousChannelCode = order.ChannelCode?.Trim();
var newChannelCode = selectedCandidate.Channel.ChannelCode?.Trim() ?? string.Empty;
var channelChanged = !string.Equals(
previousChannelCode,
newChannelCode,
StringComparison.OrdinalIgnoreCase);
order.ChannelCode = selectedCandidate.Channel.ChannelCode;
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
order,
selectedCandidate.Provider,
selectedCandidate.Channel);
// 换渠道后旧下游单号/跟踪号失效,清空以免「新渠道+旧服务商单号」。
if (channelChanged
&& (!string.IsNullOrWhiteSpace(order.DownstreamServiceNo)
|| !string.IsNullOrWhiteSpace(order.TrackNo)))
{
order.DownstreamServiceNo = null;
order.TrackNo = null;
}
// 有效应付只保留本阶段一套,避免 forecast+inbound 叠行虚增成本。
await OrderRepository.DeletePayableFeesAsync(order.Id);
await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
order.AllocationPendingReason = string.Empty;
}
private sealed class AllocationEvaluation
{
public string StoredScheme { get; init; } = AllocationSchemeCodes.CostLowest;
public string SelectScheme { get; init; } = AllocationSchemeCodes.CostLowest;
public string SchemeSource { get; init; } = "productDefault";
public decimal ChargeableWeight { get; init; }
public string ChargingStage { get; init; } = string.Empty;
public IReadOnlyList<string> ZoneKeys { get; init; } = [];
public List<AllocationResolver.ChannelCandidate> Candidates { get; init; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
public AllocationResolver.ChannelCandidate? Selected { get; set; }
public string? FailReason { get; set; }
}
private async Task<AllocationEvaluation> EvaluateAllocationAsync(
Order order,
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
IReadOnlyList<string> zoneKeys,
decimal chargeableWeight,
string chargingStage,
string? schemeOverride,
int? forceServiceChannelId)
{
var skipped = new List<OrderAllocationSkippedOutput>();
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
var productScheme = snapshotBinding.Rules.Product?.Scheme
?? product.Scheme
?? AllocationSchemeCodes.CostLowest;
var hasOverride = !string.IsNullOrWhiteSpace(schemeOverride);
var isDesignated = forceServiceChannelId is > 0
|| AllocationSchemeCodes.IsDesignated(schemeOverride);
var storedScheme = isDesignated
? AllocationSchemeCodes.Designated
: hasOverride
? schemeOverride!.Trim()
: productScheme;
var schemeSource = isDesignated
? "designated"
: hasOverride
? "reallocate"
: "productDefault";
var selectScheme = AllocationSchemeCodes.IsDesignated(storedScheme)
? AllocationSchemeCodes.CostLowest
: storedScheme;
if (forceServiceChannelId is > 0)
{
var before = filtered;
filtered = filtered
.Where(x => x.ServiceChannelId == forceServiceChannelId.Value)
.ToList();
if (filtered.Count == 0)
{
foreach (var p in before.Where(x => x.ServiceChannelId != forceServiceChannelId))
{
// 非指定渠道不记跳过;指定不在池内单独失败
}
return new AllocationEvaluation
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
FailReason = "指定渠道不在该销售产品排货池或当前客户不可用"
};
}
}
if (filtered.Count == 0)
{
return new AllocationEvaluation
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Skipped = skipped,
FailReason = "排货规则未匹配到可用服务渠道"
};
}
// 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。
var bindTime = DateTime.Now;
var candidates = new List<AllocationResolver.ChannelCandidate>();
var recipient = (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault();
var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General;
foreach (var provider in filtered)
{
var channelId = provider.ServiceChannelId!.Value;
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
if (channel == null)
{
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = channelId,
Reason = "服务渠道不存在"
});
continue;
}
if (channel.IsActive != EnableStatus.Enabled)
{
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = channel.Id,
ChannelCode = channel.ChannelCode,
ChannelName = channel.ChannelName,
Reason = "服务渠道未启用"
});
continue;
}
var channelIsRemote = await IsChannelRemoteHitAsync(
channel,
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
recipient?.ZipCode);
var payableContext = BuildBillingConditionContext(
order,
channelIsRemote ? (byte)1 : (byte)0);
var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime);
var channelRemoteZoneKeys = channelIsRemote
? ParcelBillingCalculator.BuildRemoteZoneKeys(channel.RemoteAreaId)
: Array.Empty<string>();
var previewFees = ParcelBillingCalculator.CalculatePayableFees(
channelPriceBinding.Prices,
channelPriceBinding.PriceVersion,
channelPriceBinding.PriceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
chargingStage,
order.WarehouseId,
warehouseName,
payableContext,
order.DeclaredSum,
order.DeclaredCurrency,
channelRemoteZoneKeys);
if (previewFees.Count == 0)
{
var miss = ParcelBillingCalculator.ExplainPayablePriceMiss(
channelPriceBinding.Prices,
chargeableWeight,
zoneKeys,
cargoAttribute,
order.WarehouseId,
warehouseName,
channelRemoteZoneKeys);
skipped.Add(new OrderAllocationSkippedOutput
{
ServiceChannelId = channel.Id,
ChannelCode = channel.ChannelCode,
ChannelName = channel.ChannelName,
Reason = string.IsNullOrWhiteSpace(miss)
? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})"
: $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}"
});
continue;
}
candidates.Add(new AllocationResolver.ChannelCandidate
{
Provider = provider,
Channel = channel,
TotalPayableCost = previewFees.Sum(x => x.Amount),
PayableFees = previewFees
});
}
if (candidates.Count == 0)
{
var detail = skipped.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Reason))?.Reason;
return new AllocationEvaluation
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Candidates = candidates,
Skipped = skipped,
FailReason = chargeableWeight <= 0
? "计费重无效,无法计算应付费用"
: string.IsNullOrWhiteSpace(detail)
? "排货规则未匹配到有效渠道买价"
: $"排货规则未匹配到有效渠道买价;{detail}"
};
}
var selected = AllocationResolver.SelectCandidate(selectScheme, candidates);
return new AllocationEvaluation
{
StoredScheme = storedScheme,
SelectScheme = selectScheme,
SchemeSource = schemeSource,
ChargeableWeight = chargeableWeight,
ChargingStage = chargingStage,
ZoneKeys = zoneKeys,
Candidates = candidates,
Skipped = skipped,
Selected = selected
};
}
private static void ApplyAllocationTraceToOrder(Order order, AllocationEvaluation evaluation)
{
var selectedCode = evaluation.Selected?.Channel.ChannelCode;
var output = BuildAllocationTraceOutput(
order.Id,
evaluation,
fromSnapshot: true,
selectedChannelCode: selectedCode);
order.AllocationScheme = evaluation.StoredScheme;
order.AllocationTraceJson = JsonConvert.SerializeObject(output);
order.AllocationTraceTime = DateTime.Now;
}
private static OrderAllocationTraceOutput BuildAllocationTraceOutput(
int orderId,
AllocationEvaluation evaluation,
bool fromSnapshot,
string? selectedChannelCode)
{
var selectedId = evaluation.Selected?.Channel.Id;
var candidates = evaluation.Candidates
.Select(c => new OrderAllocationCandidateOutput
{
ServiceChannelId = c.Channel.Id,
ChannelCode = c.Channel.ChannelCode,
ChannelName = c.Channel.ChannelName,
TotalPayableCost = c.TotalPayableCost,
MinTime = c.Channel.MinTime,
Weight = c.Provider.Weight,
Selected = selectedId.HasValue && c.Channel.Id == selectedId.Value,
FeeLines = c.PayableFees.Select(f => new OrderAllocationFeeLineOutput
{
CostItem = f.CostItem,
Amount = f.Amount,
Currency = f.Currency
}).ToList()
})
.ToList();
// 按方案主指标排序,选中置顶
candidates = SortAllocationCandidates(evaluation.StoredScheme, candidates);
return new OrderAllocationTraceOutput
{
OrderId = orderId,
FromSnapshot = fromSnapshot,
Scheme = evaluation.StoredScheme,
SchemeName = ResolveAllocationSchemeName(evaluation.StoredScheme),
SchemeSource = evaluation.SchemeSource,
SchemeSourceName = ResolveAllocationSchemeSourceName(evaluation.SchemeSource),
ChargeableWeight = evaluation.ChargeableWeight,
ZoneKeys = evaluation.ZoneKeys?.ToList() ?? [],
ChargingStage = evaluation.ChargingStage,
ChargingStageName = ResolveChargingStageName(evaluation.ChargingStage),
SelectedChannelCode = selectedChannelCode ?? evaluation.Selected?.Channel.ChannelCode,
SelectedChannelName = evaluation.Selected?.Channel.ChannelName,
FailReason = evaluation.FailReason,
TraceTime = DateTime.Now,
Candidates = candidates,
Skipped = evaluation.Skipped
};
}
private static List<OrderAllocationCandidateOutput> SortAllocationCandidates(
string? scheme,
List<OrderAllocationCandidateOutput> candidates)
{
Func<OrderAllocationCandidateOutput, decimal> metric = scheme switch
{
AllocationSchemeCodes.TimeFastest => x => x.MinTime ?? int.MaxValue,
AllocationSchemeCodes.OverallBest => x => -x.Weight,
_ => x => x.TotalPayableCost
};
return candidates
.OrderByDescending(x => x.Selected)
.ThenBy(metric)
.ThenBy(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string ResolveAllocationSchemeName(string? scheme) =>
scheme switch
{
AllocationSchemeCodes.TimeFastest => "时效优先",
AllocationSchemeCodes.OverallBest => "综合最优",
AllocationSchemeCodes.Designated => "指定渠道",
_ => "价格优先"
};
private static string ResolveAllocationSchemeSourceName(string? source) =>
source switch
{
"reallocate" => "运营重新排货",
"designated" => "指定渠道",
_ => "产品默认"
};
private static string ResolveChargingStageName(string? stage) =>
stage switch
{
OrderFeeChargingStageCodes.Inbound => "入库",
OrderFeeChargingStageCodes.Outbound => "出库",
OrderFeeChargingStageCodes.Forecast => "预报",
_ => stage ?? string.Empty
};
private Task ConfirmServiceChannelAndPayableAsync(
Order order,
SalesProduct product,
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
int? forceServiceChannelId = null) =>
ConfirmServiceChannelAndPayableAsync(
order,
product,
billing.SnapshotBinding,
billing.ZoneKeys,
billing.ChargeableWeight,
chargingStage,
billing.PriceTierKey,
schemeOverride,
forceServiceChannelId);
/// <summary>
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
/// </summary>
private async Task<bool> TryConfirmServiceChannelAndPayableAsync(
Order order,
SalesProduct product,
OrderBillingContext billing,
string chargingStage,
string? schemeOverride = null,
int? forceServiceChannelId = null)
{
try
{
await ConfirmServiceChannelAndPayableAsync(
order,
product,
billing,
chargingStage,
schemeOverride,
forceServiceChannelId);
return true;
}
catch (BusinessException ex)
{
var message = ex.Message.Trim();
order.AllocationPendingReason = message.Length > 512 ? message[..512] : message;
return false;
}
}
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.DeserializeChannelPriceSnapshot(priceRecord.Content);
if (snapshot?.Prices.Count > 0)
{
return new ChannelPriceBinding
{
Prices = snapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(),
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 ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
rawWeight,
ranges,
rulesSnapshot,
weightRoundingId.Value,
dbRanges);
}
catch (BusinessException)
{
throw;
}
catch (InvalidOperationException ex)
{
throw new BusinessException(ex.Message);
}
}
private async Task<IReadOnlyList<WeightRoundingInfo>> ResolveWeightRoundingRangesAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
int weightRoundingId)
{
var snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
var fromSnapshot = snapshotRanges is { Count: > 0 }
&& snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
if (fromSnapshot)
{
return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
}
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
if (rounding == null)
{
throw new BusinessException("未配置计重进位");
}
return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
}
private static decimal ApplyRoundingWithSnapshotFallback(
decimal rawWeight,
IReadOnlyList<WeightRoundingInfo> ranges,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
int weightRoundingId,
IReadOnlyList<WeightRoundingInfo> dbFallbackRanges)
{
var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
try
{
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0)
{
return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges);
}
}
/// <summary>
/// 查价专用:无尺寸时仅按实重进位计计费重(不算体积重)。
/// </summary>
private async Task ApplyQuoteChargeableWeightFromActualOnlyAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
OrderWeight forecast)
{
var actualWeight = forecast.ActualWeight ?? 0;
if (actualWeight <= 0)
{
throw new BusinessException("货物重量无效");
}
forecast.VolumetricWeight = 0;
forecast.Cubic = null;
var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot);
if (!weightRoundingId.HasValue)
{
throw new BusinessException("未配置计重进位");
}
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
var dbRanges = ranges;
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)))
{
dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
}
try
{
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
actualWeight,
ranges,
rulesSnapshot,
weightRoundingId.Value,
dbRanges);
}
catch (InvalidOperationException ex)
{
throw new BusinessException(ex.Message);
}
}
private static void EnsureBillingProductActive(SalesProduct product)
{
if (product.IsActive != EnableStatus.Enabled)
{
throw new BusinessException("订单关联的销售产品未启用,无法计费");
}
}
private static void ApplyMeasuredDimensions(
OrderWeight weightRow,
decimal length,
decimal width,
decimal height,
decimal actualWeight)
{
weightRow.Length = length;
weightRow.Width = width;
weightRow.Height = height;
weightRow.ActualWeight = actualWeight;
weightRow.Cubic = ParcelBillingCalculator.CalculateCubic(length, width, height);
}
private static int? ResolveEffectiveWeightRuleId(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
{
if (product.WeightRuleId is > 0)
{
return product.WeightRuleId;
}
if (rulesSnapshot?.WeightRuleId is > 0)
{
return rulesSnapshot.WeightRuleId;
}
if (rulesSnapshot?.Product?.WeightRuleId is > 0)
{
return rulesSnapshot.Product.WeightRuleId;
}
return null;
}
private static int? ResolveEffectiveWeightRoundingId(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
{
if (product.WeightRoundingId is > 0)
{
return product.WeightRoundingId;
}
if (rulesSnapshot?.WeightRoundingId is > 0)
{
return rulesSnapshot.WeightRoundingId;
}
if (rulesSnapshot?.Product?.WeightRoundingId is > 0)
{
return rulesSnapshot.Product.WeightRoundingId;
}
return null;
}
private static List<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,
InboundMethod = string.IsNullOrWhiteSpace(createContext?.InboundMethod)
? null
: createContext!.InboundMethod!.Trim(),
IsCod = input.IsCod ? (byte)1 : (byte)0,
CodCurrency = input.CodCurrency,
CodSum = input.CodSum,
DeclaredCurrency = input.DeclaredCurrency,
DeclaredSum = input.DeclaredSum,
Quantity = input.Quantity,
IsIntercept = 0,
IsRemote = 0,
AdditionalServices = SerializeAdditionalServices(input.AdditionalServices),
FulfillmentType = FulfillmentTypeCodes.Direct,
PredeclareFailReason = string.Empty,
AllocationPendingReason = string.Empty,
SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion,
LabelTemplateId = product.LabelTemplateId,
OrderStatus = OrderStatusCodes.Draft
};
}
private async Task SaveParcelChildrenAsync(
Order order,
OrderParcelCreateInput input,
List<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 = FileStorage.ToStorageKey(x.SkuImage),
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0,
Distribution = x.Distribution,
Material = x.Material,
EnMaterial = x.EnMaterial,
Purpose = x.Purpose,
Attributes = x.Attributes,
OriginCountry = x.OriginCountry,
SalesLink = x.SalesLink,
Model = x.Model,
Specifications = x.Specifications,
Comment = x.Comment
}));
}
await OrderRepository.InsertWeightsAsync(weights);
await OrderRepository.UpdateAsync(order);
}
private async Task<string> ResolveServiceNoAsync()
{
for (var i = 0; i < 5; i++)
{
var generated = BuildParcelServiceNo(DateTime.Now, Random.Shared.Next(1000, 9999));
if (!await OrderRepository.HasServiceNoAsync(generated))
{
return generated;
}
}
throw new BusinessException("运单号生成失败");
}
/// <summary>
/// 草稿重新预报:按订单当前收件/件重尺/快照重跑黑名单、计重、分区与预报应收。
/// 失败时保持草稿并写入 PredeclareFailReason。
/// </summary>
internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null)
{
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法预报");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null || product.IsActive != EnableStatus.Enabled)
{
throw new BusinessException("销售产品不存在或未启用");
}
try
{
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var address = await ResolveOrderBillingAddressAsync(order);
if (await IsBlacklistHitAsync(
product,
snapshotBinding.Rules,
address.CountryCode,
address.Province,
address.City,
address.ZipCode))
{
throw new BusinessException("收件地址命中黑名单区域");
}
order.IsRemote = await IsRemoteHitAsync(
product,
snapshotBinding.Rules,
address.CountryCode,
address.Province,
address.City,
address.ZipCode) ? (byte)1 : (byte)0;
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var forecast = weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase))
?? throw new BusinessException("订单缺少预报件重尺");
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var billing = await ResolveOrderBillingContextAsync(
order,
product,
forecast.ChargeableWeight ?? 0,
snapshotBinding);
await UpsertReceivableFeesAsync(
order,
billing,
OrderFeeChargingStageCodes.Forecast,
replaceExistingReceivable: true);
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.AllocationPendingReason = string.Empty;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null, appendInitialTrack: true);
await EnqueueGenerateLabelPdfAsync(order);
try
{
var triggerUserId = operatorUserId ?? CurrentUser.GetId<int>();
await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId);
}
catch
{
// 自动触发失败不影响预报主流程
}
}
catch (BusinessException ex)
{
var cleanReason = StripDraftSavedMarker(ex.Message);
await MarkParcelDraftFailedAsync(order, cleanReason);
throw CreateDraftSavedBusinessException(order, cleanReason);
}
catch (Exception ex)
{
var cleanReason = StripDraftSavedMarker(ex.Message);
await MarkParcelDraftFailedAsync(order, cleanReason);
throw CreateDraftSavedBusinessException(order, cleanReason);
}
}
private async Task MarkParcelDraftFailedAsync(Order order, string reason)
{
order.OrderStatus = OrderStatusCodes.Draft;
order.PredeclareFailReason = reason;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
/// <summary>
/// 入库收货:同步只写入 receive 件重尺与收货时间,状态先置待排货;
/// 应收/应付/排货走异步任务,失败不回滚入库。
/// </summary>
internal async Task ApplyReceiveBillingByWaybillAsync(
string waybillNo,
decimal? length,
decimal? width,
decimal? height,
decimal? actualWeight)
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
throw new BusinessException("运单号不能为空");
}
ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "收货");
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
if (!InboundBillingInFlight.TryAdd(order.Id, 0))
{
throw new BusinessException("订单入库处理中,请勿重复扫描");
}
try
{
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
if (receive == null)
{
throw new BusinessException("订单缺少收货件重尺记录");
}
ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
// 尽力算计费重,失败留给异步计费重试,不阻断入库扫码。
try
{
if (!string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product != null)
{
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
}
}
}
catch
{
// ignore
}
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = InboundBillingProcessingReason;
order.ReceiveTime = DateTime.Now;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode);
await EnqueueInboundBillingAsync(order);
}
finally
{
InboundBillingInFlight.TryRemove(order.Id, out _);
}
}
/// <summary>
/// 入库异步计费:重算 inbound 应收 + 尝试排货写应付;失败仅落待排货原因。
/// </summary>
internal async Task ProcessInboundBillingForBatchAsync(
int orderId,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
var order = await GetOrderOrThrowAsync(orderId);
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = "订单未绑定销售产品,无法入库计费";
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return;
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = "订单关联的销售产品不存在";
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return;
}
try
{
EnsureBillingProductActive(product);
}
catch (BusinessException ex)
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = TrimPendingReason(ex.Message);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return;
}
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
if (receive == null)
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = "订单缺少收货件重尺记录";
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return;
}
ProductSnapshotBinding snapshotBinding;
try
{
snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
}
catch (Exception ex)
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = TrimPendingReason(
ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}");
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return;
}
OrderBillingContext billing;
try
{
billing = await ResolveOrderBillingContextAsync(
order,
product,
receive.ChargeableWeight ?? 0,
snapshotBinding);
}
catch (Exception ex)
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = TrimPendingReason(
ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}");
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return;
}
var (receivableOk, receivableFailReason) = await TryUpsertInboundReceivableFeesAsync(order, billing);
var allocated = await TryConfirmServiceChannelAndPayableAsync(
order,
product,
billing,
OrderFeeChargingStageCodes.Inbound);
try
{
await RefreshMarketingPayableAmountAsync(order);
}
catch
{
// 营销金额刷新失败不影响入库结果
}
if (allocated && receivableOk)
{
order.OrderStatus = OrderStatusCodes.PendingPayment;
order.AllocationPendingReason = string.Empty;
}
else
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
// 排货成功会清空 AllocationPendingReason,需用应收失败原因回填
if (!receivableOk && !string.IsNullOrWhiteSpace(receivableFailReason))
{
order.AllocationPendingReason = receivableFailReason;
}
else if (string.IsNullOrWhiteSpace(order.AllocationPendingReason))
{
order.AllocationPendingReason = allocated
? "入库应收未匹配到有效卖价"
: "排货未匹配到可用服务渠道或买价";
}
}
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
if (allocated)
{
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null);
}
}
private async Task<(bool Ok, string? FailReason)> TryUpsertInboundReceivableFeesAsync(
Order order,
OrderBillingContext billing)
{
try
{
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
await UpsertReceivableFeesAsync(
order,
billing,
OrderFeeChargingStageCodes.Inbound);
return (true, null);
}
catch (BusinessException ex)
{
return (false, TrimPendingReason(ex.Message));
}
catch (Exception ex)
{
return (false, TrimPendingReason($"入库应收计费失败:{ex.Message}"));
}
}
private static string TrimPendingReason(string? message)
{
var text = (message ?? string.Empty).Trim();
if (text.Length <= 512)
{
return text;
}
return text[..512];
}
private async Task EnqueueInboundBillingAsync(Order order)
{
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("Oms.InboundBilling");
try
{
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
if (!publisher.IsEnabled)
{
// 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成)
try
{
await ProcessInboundBillingForBatchAsync(order.Id);
}
catch (Exception ex)
{
logger.LogError(ex, "入库计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo);
order.OrderStatus = OrderStatusCodes.PendingAllocation;
order.AllocationPendingReason = TrimPendingReason($"入库计费失败:{ex.Message}");
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
return;
}
var requestId = await ResolveInboundBillingRequestIdAsync(order.Id);
if (requestId == null)
{
logger.LogInformation(
"入库计费任务已在队列中,跳过重复投递 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
return;
}
await BatchJobCoordinator.CreateAndEnqueueAsync(
new OrderBatchJobCreateInput
{
Action = OrderBatchActionCodes.InboundBilling,
OrderIds = [order.Id],
RequestId = requestId
},
CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
}
catch (Exception ex)
{
// 投递失败不回滚入库;落待排货并记错误日志,供运营重跑入库计费
logger.LogError(
ex,
"入库计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
try
{
order.OrderStatus = OrderStatusCodes.PendingAllocation;
if (string.IsNullOrWhiteSpace(order.AllocationPendingReason)
|| order.AllocationPendingReason == InboundBillingProcessingReason)
{
order.AllocationPendingReason = InboundBillingEnqueueFailReason;
}
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
catch
{
// ignore
}
}
}
/// <summary>
/// 幂等 RequestId:进行中任务复用并跳过;已结束任务换新 ID 允许重跑。
/// 返回 null 表示已有进行中任务,无需再投递。
/// </summary>
private async Task<string?> ResolveInboundBillingRequestIdAsync(int orderId)
{
var batchJobRepository = LazyServiceProvider.GetRequiredService<IOrderBatchJobRepository>();
var stableId = $"inbound-billing:{orderId}";
var existing = await batchJobRepository.GetByRequestIdAsync(stableId);
if (existing != null)
{
if (IsActiveBatchJob(existing.Status))
{
return null;
}
// 旧稳定 ID 已结束,后续用带时间戳 ID,避免被已完成任务挡住重跑
return $"inbound-billing:{orderId}:{DateTime.UtcNow:yyyyMMddHHmmssfff}";
}
return stableId;
}
private static bool IsActiveBatchJob(string? status) =>
string.Equals(status, OrderBatchJobStatusCodes.Pending, StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, OrderBatchJobStatusCodes.Running, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 出库发货后:仅覆盖 ship 件重尺、重算计费重并更新状态;不再重算应收。
/// </summary>
internal async Task ApplyShipBillingByWaybillAsync(
string waybillNo,
decimal? length,
decimal? width,
decimal? height,
decimal? actualWeight)
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
throw new BusinessException("运单号不能为空");
}
ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "发货");
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法出库计费");
}
if (order.IsPaid == 0)
{
throw new BusinessException("订单未付款,禁止出库");
}
if (order.OrderStatus != OrderStatusCodes.PendingShip)
{
if (order.OrderStatus is OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed)
{
throw new BusinessException("订单已出库");
}
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许出库");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("订单关联的销售产品不存在");
}
EnsureBillingProductActive(product);
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var ship = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Ship);
if (ship == null)
{
throw new BusinessException("订单缺少发货件重尺记录");
}
ApplyMeasuredDimensions(ship, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
// 出库仅更新件重尺与状态,不再重算/覆盖应收。
var now = DateTime.Now;
order.OrderStatus = OrderStatusCodes.PendingOnline;
order.ShipTime = now;
order.UpdateTime = now;
await OrderRepository.UpdateAsync(order);
EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.OutboundTrackCode);
}
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
{
var mapped = (await MapOrderOutputsAsync([order])).First();
var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id);
var labelUrl = systemLabelUrl ?? string.Empty;
if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared)
{
// 回落 HTML 面单仅用于返回 LabelUrl;写盘失败不得回滚已预报成功的订单。
try
{
labelUrl = await CreateOrderPrintFileAsync(mapped);
}
catch (Exception ex)
{
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("Oms.OrderParcelCreate");
logger.LogWarning(
ex,
"预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
}
}
return new OrderParcelCreateOutput
{
OrderId = order.Id,
ClientNo = order.ClientNo,
ServiceNo = order.ServiceNo,
TrackNo = order.TrackNo,
ConsigneeCountry = order.ConsigneeCountry,
ConsigneeCountryName = order.ConsigneeCountryName,
LabelUrl = labelUrl
};
}
private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderNotifier>();
private async Task EnqueueGenerateLabelPdfAsync(Order order)
{
try
{
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
if (!publisher.IsEnabled)
{
// 本地未开队列:同步生成,避免 Pending 悬挂
var mapped = (await MapOrderOutputsAsync([order])).First();
await LabelPdfGenerationService.GenerateAndPersistAsync(
order,
mapped,
operatorUserId: CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
return;
}
await BatchJobCoordinator.CreateAndEnqueueAsync(
new OrderBatchJobCreateInput
{
Action = OrderBatchActionCodes.GenerateLabelPdf,
OrderIds = [order.Id],
RequestId = $"label-pdf:{order.Id}:{Guid.NewGuid():N}"
},
CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
}
catch (Exception)
{
// 投递失败不阻断创单;打印时会补生成
}
}
/// <summary>
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
/// </summary>
private async Task EnqueueSmallBagTrackUpsertAsync(
Order order,
Customer? customer,
OrderParcelReceiverInput? receiver,
bool appendInitialTrack = false)
{
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
request.AppendInitialTrackOnSuccess = appendInitialTrack;
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{
request.Authorization = authorization;
}
SmallBagTrackOrderNotifier.Enqueue(request, order.Id, customer?.Id);
}
private void EnqueueLifecycleTrackAppend(Order order, string trackCode)
{
if (string.IsNullOrWhiteSpace(order.ServiceNo))
{
return;
}
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
SmallBagTrackOrderNotifier.EnqueueLifecycleTrackAppend(
order.ServiceNo,
trackCode,
order.Id,
string.IsNullOrWhiteSpace(authorization) ? null : authorization);
}
/// <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);
request.AppendInitialTrackOnSuccess = true;
var authorization = LazyServiceProvider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext?
.Request
.Headers
.Authorization
.ToString();
if (!string.IsNullOrWhiteSpace(authorization))
{
request.Authorization = authorization;
}
return await SmallBagTrackOrderClient.UpsertDebugAsync(request);
}
private async Task<SmallBagTrackUpsertRequest> BuildSmallBagTrackUpsertRequestAsync(
Order order,
OrderParcelReceiverInput? receiverInput = null)
{
return await SmallBagTrackUpsertRequestBuilder.BuildAsync(order, receiverInput);
}
private ISmallBagTrackUpsertRequestBuilder SmallBagTrackUpsertRequestBuilder =>
LazyServiceProvider.GetRequiredService<ISmallBagTrackUpsertRequestBuilder>();
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);
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var receivableSubtotal = fees
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
.Sum(x => x.Amount);
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
var breakdown = await marketingHelper.BuildBreakdownAsync(order.CustomerId, receivableSubtotal, couponInstanceId);
marketingHelper.ApplySnapshotToOrder(order, breakdown, couponInstanceId);
if (breakdown.CouponInstanceId.HasValue && order.CustomerId.HasValue)
{
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(order.CustomerId.Value);
await couponManager.LockForOrderAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id);
}
}
/// <summary>
/// 预报计费完成后固化应收/应付快照;入库重算不得覆盖。
/// </summary>
private async Task CaptureForecastBillingSnapshotAsync(Order order)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
order.ForecastReceivableAmount = order.ReceivableSubtotal
?? fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount);
order.ForecastPayableAmount = fees
.Where(x => x.Type == OrderFeeTypeCodes.Payable)
.Sum(x => x.Amount);
}
/// <summary>
/// 入库等场景重算应收后,按当前应收小计刷新营销应付金额(不重新锁定优惠券)。
/// </summary>
private async Task RefreshMarketingPayableAmountAsync(Order order)
{
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
var receivableSubtotal = fees
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
.Sum(x => x.Amount);
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
var breakdown = await marketingHelper.BuildBreakdownAsync(
order.CustomerId,
receivableSubtotal,
order.CouponInstanceId);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
}
private static void EnsureReceivableMutable(Order order)
{
if (order.IsPaid != 0)
{
throw new BusinessException("订单已付款,不能修改应收费用");
}
}
private static void EnsureAdditionalServicesAllowed(SalesProduct product, List<string>? selected)
{
var selectedCodes = NormalizeAdditionalServiceCodes(selected);
if (selectedCodes.Count == 0)
{
return;
}
var allowed = ParseAdditionalServiceCodes(product.AdditionalServices);
if (allowed.Count == 0)
{
throw new BusinessException("销售产品未配置附加服务,不能勾选附加服务");
}
var invalid = selectedCodes
.Where(code => !allowed.Contains(code))
.ToList();
if (invalid.Count > 0)
{
throw new BusinessException($"附加服务不在产品可选范围:{string.Join(", ", invalid)}");
}
}
private static string? SerializeAdditionalServices(List<string>? codes)
{
var normalized = NormalizeAdditionalServiceCodes(codes);
return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized);
}
private static List<string> NormalizeAdditionalServiceCodes(IEnumerable<string>? codes) =>
(codes ?? [])
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static HashSet<string> ParseAdditionalServiceCodes(string? json)
{
var codes = OmsDocHelper.DeserializeJson<List<string>>(json) ?? [];
return NormalizeAdditionalServiceCodes(codes)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static OrderRuleEvaluationContext BuildBillingConditionContext(
Order order,
byte? overrideIsRemote = null)
{
var evalOrder = overrideIsRemote.HasValue
? new Order
{
Id = order.Id,
ProductCode = order.ProductCode,
ChannelCode = order.ChannelCode,
ConsigneeCountry = order.ConsigneeCountry,
ConsigneeCountryName = order.ConsigneeCountryName,
CargoAttribute = order.CargoAttribute,
WarehouseId = order.WarehouseId,
CustomerId = order.CustomerId,
DeclaredCurrency = order.DeclaredCurrency,
DeclaredSum = order.DeclaredSum,
Quantity = order.Quantity,
IsRemote = overrideIsRemote.Value,
AdditionalServices = order.AdditionalServices,
IsPaid = order.IsPaid,
IsCod = order.IsCod,
CodCurrency = order.CodCurrency,
CodSum = order.CodSum
}
: order;
return OrderRuleEvaluationContext.FromOrderOnly(evalOrder);
}
private async Task<bool> IsChannelRemoteHitAsync(
ServiceChannel channel,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (channel.RemoteAreaId is not > 0)
{
return false;
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(channel.RemoteAreaId.Value);
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
countryCode,
province,
city,
zipCode));
}
}