feat: 小包全链路加固、待排货与运单号 IDE 前缀

查价/下单/入库/出库统一校验与计费上下文;待排货状态及可选入库排货;运单号改为 IDE+yyMMddHHmmss;补充迁移 SQL 与测试修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-22 19:00:55 +08:00
parent c2d854b83f
commit 3d5eaa223a
28 changed files with 1250 additions and 375 deletions

1
db.sql
View File

@ -114,6 +114,7 @@ CREATE TABLE `t_order` (
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive', `CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式', `ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因', `PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
`AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)',
`SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本', `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本',
`SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本', `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本',
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态', `OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',

View File

@ -0,0 +1,12 @@
-- 订单状态:待排货(入库排货/渠道买价未成功)
-- 可重复执行(INSERT IGNORE)
INSERT IGNORE INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `CreateUserId`)
VALUES ('order_status', 35, 'pendingAllocation', '待排货', 0);
-- 历史数据:有排货失败原因但状态仍为待支付的订单,修正为待排货
UPDATE `t_order`
SET `OrderStatus` = 'pendingAllocation',
`UpdateTime` = NOW()
WHERE TRIM(IFNULL(`AllocationPendingReason`, '')) <> ''
AND `OrderStatus` = 'pendingPayment';

View File

@ -0,0 +1,3 @@
-- 待排货原因:入库排货/买价未成功时记录,便于运营补配置后重算应付
ALTER TABLE `t_order`
ADD COLUMN `AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)' AFTER `PredeclareFailReason`;

View File

@ -135,6 +135,11 @@ public class OrderOutput
/// </summary> /// </summary>
public string? PredeclareFailReason { get; set; } public string? PredeclareFailReason { get; set; }
/// <summary>
/// 待排货原因(排货或渠道买价未匹配,空表示已排货或无待办)。
/// </summary>
public string? AllocationPendingReason { get; set; }
/// <summary> /// <summary>
/// 申报币种。 /// 申报币种。
/// </summary> /// </summary>

View File

@ -0,0 +1,32 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 查价提示:缺失条件与免责声明(避免客户误以为报价为最终价)。
/// </summary>
public class OrderPriceCheckHintOutput
{
/// <summary>
/// 报价精度:exact=条件较完整;estimated=含估算项,仅供参考。
/// </summary>
public string QuoteLevel { get; set; } = PriceCheckQuoteLevelCodes.Exact;
/// <summary>
/// 缺失或未指定的入参字段编码(dimensions/postalCode/cargoType/warehouse 等)。
/// </summary>
public List<string> MissingFieldCodes { get; set; } = [];
/// <summary>
/// 面向客户的提示文案。
/// </summary>
public List<string> Disclaimers { get; set; } = [];
}
/// <summary>
/// 查价报价精度。
/// </summary>
public static class PriceCheckQuoteLevelCodes
{
public const string Exact = "exact";
public const string Estimated = "estimated";
}

View File

@ -104,4 +104,24 @@ public class OrderPriceCheckOutput
/// 是否偏远。 /// 是否偏远。
/// </summary> /// </summary>
public bool IsRemote { get; set; } public bool IsRemote { get; set; }
/// <summary>
/// 报价精度:exact / estimated。
/// </summary>
public string QuoteLevel { get; set; } = PriceCheckQuoteLevelCodes.Exact;
/// <summary>
/// 本行报价涉及的缺失条件编码。
/// </summary>
public List<string> MissingFieldCodes { get; set; } = [];
/// <summary>
/// 本行对客提示(如未填邮编、未填尺寸等)。
/// </summary>
public List<string> Disclaimers { get; set; } = [];
/// <summary>
/// 命中的价格分区名称/键(展示用)。
/// </summary>
public string? ZoneName { get; set; }
} }

View File

@ -14,4 +14,9 @@ public class OrderPriceCheckResultOutput
/// 试算失败或未命中的产品及原因。 /// 试算失败或未命中的产品及原因。
/// </summary> /// </summary>
public List<OrderPriceCheckFailureOutput> Failures { get; set; } = []; public List<OrderPriceCheckFailureOutput> Failures { get; set; } = [];
/// <summary>
/// 本次查价入参完整度与全局免责声明。
/// </summary>
public OrderPriceCheckHintOutput? QueryHint { get; set; }
} }

View File

@ -383,7 +383,9 @@ public partial class ServiceChannelService
private static List<ServiceChannelPriceFactor> BuildPriceFactors(IEnumerable<ChannelPriceFactorInput> inputs, int? priceId = null) private static List<ServiceChannelPriceFactor> BuildPriceFactors(IEnumerable<ChannelPriceFactorInput> inputs, int? priceId = null)
{ {
return inputs.Select(input => new ServiceChannelPriceFactor return inputs
.Where(input => !IsCustomerPricingFactor(input.Factor))
.Select(input => new ServiceChannelPriceFactor
{ {
PriceId = priceId ?? 0, PriceId = priceId ?? 0,
SerialNumber = input.SerialNumber, SerialNumber = input.SerialNumber,
@ -394,16 +396,78 @@ public partial class ServiceChannelService
private static List<ServiceChannelPriceDetail> BuildPriceDetails(IEnumerable<ChannelPriceDetailInput> inputs, int? priceId = null) private static List<ServiceChannelPriceDetail> BuildPriceDetails(IEnumerable<ChannelPriceDetailInput> inputs, int? priceId = null)
{ {
return inputs.Select(input => new ServiceChannelPriceDetail return inputs.Select(input =>
{
var calculatedValue = input.CalculatedValue;
if ((calculatedValue == null || calculatedValue == 0) && input.CustomerGroupValues != null)
{
calculatedValue = ExtractFirstCustomerGroupPrice(input.CustomerGroupValues);
}
return new ServiceChannelPriceDetail
{ {
PriceId = priceId ?? 0, PriceId = priceId ?? 0,
FactorValuesJson = OmsDocHelper.SerializeJson(input.FactorValuesJson), FactorValuesJson = OmsDocHelper.SerializeJson(input.FactorValuesJson),
CalculationFormula = input.CalculationFormula, CalculationFormula = input.CalculationFormula,
CalculatedValue = input.CalculatedValue, CalculatedValue = calculatedValue,
CustomerGroupValues = OmsDocHelper.SerializeJson(input.CustomerGroupValues) CustomerGroupValues = null
};
}).ToList(); }).ToList();
} }
/// <summary>
/// 渠道买价不含客户组因素;保存时丢弃客户组列,并将历史数据首价迁移到 CalculatedValue。
/// </summary>
private static bool IsCustomerPricingFactor(string? factor)
{
if (string.IsNullOrWhiteSpace(factor))
{
return false;
}
var value = factor.Trim();
return value.Equals("customer", StringComparison.OrdinalIgnoreCase)
|| value.Equals("客户", StringComparison.Ordinal)
|| value.Equals("客户组", StringComparison.Ordinal);
}
private static decimal? ExtractFirstCustomerGroupPrice(object? customerGroupValues)
{
if (customerGroupValues == null)
{
return null;
}
var json = OmsDocHelper.SerializeJson(customerGroupValues);
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
var map = OmsDocHelper.DeserializeJson<Dictionary<string, decimal>>(json);
if (map == null || map.Count == 0)
{
return null;
}
foreach (var pair in map)
{
if (pair.Value > 0)
{
return pair.Value;
}
}
return map.Values.FirstOrDefault();
}
catch
{
return null;
}
}
private static ServiceChannelPriceSaveInput MapPriceSaveInput(ServiceChannelPriceListOutput output) private static ServiceChannelPriceSaveInput MapPriceSaveInput(ServiceChannelPriceListOutput output)
{ {
return new ServiceChannelPriceSaveInput return new ServiceChannelPriceSaveInput

View File

@ -95,11 +95,14 @@ public class InboundRecordService : ApplicationService
throw new BusinessException("未找到对应订单,请检查单号"); throw new BusinessException("未找到对应订单,请检查单号");
} }
if (!input.Weight.HasValue || !input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue ParcelBillingValidators.EnsurePositiveDimensions(
|| input.Weight <= 0 || input.Length <= 0 || input.Width <= 0 || input.Height <= 0) input.Length,
{ input.Width,
throw new BusinessException("收货实重与长宽高不能为空且须大于 0"); input.Height,
} input.Weight,
"收货");
ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
int? salesProductId = null; int? salesProductId = null;
string? destinationCountry = order.ConsigneeCountry; string? destinationCountry = order.ConsigneeCountry;

View File

@ -0,0 +1,33 @@
using Oms.Application.Dtos.Orders;
using Oms.Domain.CustomersAggregate.Enums;
namespace Oms.Application.Services.Orders;
/// <summary>
/// 算价用的收件地址(国家 + 省市区邮编)。
/// </summary>
internal readonly record struct OrderBillingAddress(
string CountryCode,
string? Province,
string? City,
string? ZipCode);
/// <summary>
/// 订单单次算价上下文:快照、分区键、计费重、货类、客户组、仓库。
/// </summary>
internal sealed class OrderBillingContext
{
public required ProductSnapshotBinding SnapshotBinding { get; init; }
public required IReadOnlyList<string> ZoneKeys { get; init; }
public required decimal ChargeableWeight { get; init; }
public required string CargoAttribute { get; init; }
public CustomerGroup? CustomerGroup { get; init; }
public int? WarehouseId { get; init; }
public string? WarehouseName { get; init; }
}

View File

@ -149,6 +149,9 @@ public partial class OrderService
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason) PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
? null ? null
: order.PredeclareFailReason.Trim(), : order.PredeclareFailReason.Trim(),
AllocationPendingReason = string.IsNullOrWhiteSpace(order.AllocationPendingReason)
? null
: order.AllocationPendingReason.Trim(),
DeclaredCurrency = order.DeclaredCurrency, DeclaredCurrency = order.DeclaredCurrency,
DeclaredSum = order.DeclaredSum, DeclaredSum = order.DeclaredSum,
Quantity = order.Quantity, Quantity = order.Quantity,

View File

@ -331,32 +331,15 @@ public partial class OrderService
} }
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); var billing = await ResolveOrderBillingContextAsync(
var recipient = recipients.FirstOrDefault(); order,
var zoneKeys = await ResolvePriceZoneKeysAsync(
product, product,
snapshotBinding.Rules, weightRow.ChargeableWeight ?? 0,
snapshotBinding.Price, snapshotBinding);
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( await UpsertReceivableFeesAsync(
order, order,
snapshotBinding, billing,
weightRow.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
chargingStage, chargingStage,
replaceExistingReceivable: true); replaceExistingReceivable: true);
@ -385,34 +368,24 @@ public partial class OrderService
} }
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); var billing = await ResolveOrderBillingContextAsync(
var recipient = recipients.FirstOrDefault(); order,
var zoneKeys = await ResolvePriceZoneKeysAsync(
product, product,
snapshotBinding.Rules, weightRow.ChargeableWeight ?? 0,
snapshotBinding.Price, snapshotBinding);
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
recipient?.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var (_, chargingStage) = ResolveReceivableBillingStage(order); var (_, chargingStage) = ResolveReceivableBillingStage(order);
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await ConfirmServiceChannelAndPayableAsync( await ConfirmServiceChannelAndPayableAsync(
order, order,
product, product,
snapshotBinding, billing,
zoneKeys, chargingStage);
weightRow.ChargeableWeight ?? 0,
chargingStage, if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
customer?.CustomerGroup); {
order.OrderStatus = OrderStatusCodes.PendingPayment;
}
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
@ -487,7 +460,7 @@ public partial class OrderService
private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) => private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) =>
order.OrderStatus switch order.OrderStatus switch
{ {
OrderStatusCodes.Received or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip OrderStatusCodes.Received or OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip
=> (OrderWeightTypeCodes.Receive, OrderFeeChargingStageCodes.Inbound), => (OrderWeightTypeCodes.Receive, OrderFeeChargingStageCodes.Inbound),
OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed

View File

@ -128,41 +128,29 @@ public partial class OrderService
SyncWeightRows(weights, forecast); SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights); await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var zoneKeys = await ResolvePriceZoneKeysAsync( var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var billing = await ResolveBillingContextAsync(
product, product,
snapshotBinding.Rules, snapshotBinding,
snapshotBinding.Price, new OrderBillingAddress(
country.CountryCode, country.CountryCode,
input.Receiver?.Province, input.Receiver?.Province,
input.Receiver?.City, input.Receiver?.City,
input.Receiver?.ZipCode); 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, forecast.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General, order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup, order.WarehouseId,
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); customer?.CustomerGroup);
await UpsertReceivableFeesAsync(
order,
billing,
OrderFeeChargingStageCodes.Forecast);
// 创建订单不对客户暴露排货/买价:渠道与应付在入库等环节由运营侧处理。
order.OrderStatus = OrderStatusCodes.Predeclared; order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty; order.PredeclareFailReason = string.Empty;
order.AllocationPendingReason = string.Empty;
order.IsRemote = order.IsRemote; order.IsRemote = order.IsRemote;
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
@ -194,7 +182,13 @@ public partial class OrderService
} }
} }
private const string ParcelServiceNoPrefix = "ID"; 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) private static void NormalizeParcelCreateInput(OrderParcelCreateInput input)
{ {
@ -202,6 +196,10 @@ public partial class OrderService
input.ServiceNo = null; input.ServiceNo = null;
input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim(); input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim();
input.RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.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) private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
@ -234,8 +232,8 @@ public partial class OrderService
throw new BusinessException("仓库ID不能为空"); throw new BusinessException("仓库ID不能为空");
} }
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue if (!ParcelBillingValidators.HasFullDimensions(input.Length, input.Width, input.Height)
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0) || !input.ActualWeight.HasValue || input.ActualWeight <= 0)
{ {
throw new BusinessException("件重尺必须大于0"); throw new BusinessException("件重尺必须大于0");
} }
@ -437,6 +435,141 @@ public partial class OrderService
}; };
} }
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<CustomerGroup?> ResolveCustomerGroupAsync(int? customerId)
{
if (!customerId.HasValue || customerId.Value <= 0)
{
return null;
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value);
return customer?.CustomerGroup;
}
/// <summary>
/// 解析算价上下文(快照 + 分区 + 仓库 + 客户组)。供下单前或仅有地址时使用。
/// </summary>
private async Task<OrderBillingContext> ResolveBillingContextAsync(
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
OrderBillingAddress address,
decimal chargeableWeight,
string cargoAttribute,
int? warehouseId,
CustomerGroup? customerGroup = null)
{
if (chargeableWeight <= 0)
{
throw new BusinessException("计费重无效,无法计费");
}
var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, snapshotBinding, address);
var warehouseName = await ResolveWarehouseNameAsync(warehouseId);
return new OrderBillingContext
{
SnapshotBinding = snapshotBinding,
ZoneKeys = zoneKeys,
ChargeableWeight = chargeableWeight,
CargoAttribute = cargoAttribute,
CustomerGroup = customerGroup,
WarehouseId = warehouseId,
WarehouseName = warehouseName
};
}
/// <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 customerGroup = await ResolveCustomerGroupAsync(order.CustomerId);
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
return new OrderBillingContext
{
SnapshotBinding = binding,
ZoneKeys = zoneKeys,
ChargeableWeight = chargeableWeight,
CargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General,
CustomerGroup = customerGroup,
WarehouseId = order.WarehouseId,
WarehouseName = warehouseName
};
}
private async Task UpsertReceivableFeesAsync(
Order order,
OrderBillingContext billing,
string chargingStage,
bool replaceExistingReceivable = false)
{
await UpsertReceivableFeesAsync(
order,
billing.SnapshotBinding,
billing.ChargeableWeight,
billing.ZoneKeys,
billing.CargoAttribute,
billing.CustomerGroup,
chargingStage,
replaceExistingReceivable,
billing.WarehouseId,
billing.WarehouseName);
}
private async Task UpsertReceivableFeesAsync( private async Task UpsertReceivableFeesAsync(
Order order, Order order,
ProductSnapshotBinding snapshotBinding, ProductSnapshotBinding snapshotBinding,
@ -445,8 +578,15 @@ public partial class OrderService
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
string chargingStage, string chargingStage,
bool replaceExistingReceivable = false) bool replaceExistingReceivable = false,
int? warehouseId = null,
string? warehouseName = null)
{ {
if (chargeableWeight <= 0)
{
throw new BusinessException("计费重无效,无法计算应收费用");
}
var fees = ParcelBillingCalculator.CalculateReceivableFees( var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshotBinding.Price, snapshotBinding.Price,
snapshotBinding.PriceVersion, snapshotBinding.PriceVersion,
@ -455,11 +595,21 @@ public partial class OrderService
zoneKeys, zoneKeys,
cargoAttribute, cargoAttribute,
customerGroup, customerGroup,
chargingStage); chargingStage,
warehouseId ?? order.WarehouseId,
warehouseName);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price)) if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
{ {
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格"); var detail = ParcelBillingCalculator.ExplainQuotePriceMiss(
snapshotBinding.Price,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
warehouseId ?? order.WarehouseId,
warehouseName);
throw new BusinessException($"产品卖价未匹配到有效价格:{detail}");
} }
if (fees.Count > 0) if (fees.Count > 0)
@ -475,6 +625,7 @@ public partial class OrderService
/// <summary> /// <summary>
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。 /// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
/// 仅用于入库、重算应付等内部环节;创建订单时不调用。
/// </summary> /// </summary>
private async Task ConfirmServiceChannelAndPayableAsync( private async Task ConfirmServiceChannelAndPayableAsync(
Order order, Order order,
@ -483,8 +634,14 @@ public partial class OrderService
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
decimal chargeableWeight, decimal chargeableWeight,
string chargingStage, string chargingStage,
CustomerGroup? customerGroup) CustomerGroup? _)
{ {
if (chargeableWeight <= 0)
{
throw new BusinessException("计费重无效,无法计算应付费用");
}
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId); var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
if (filtered.Count == 0) if (filtered.Count == 0)
@ -514,8 +671,9 @@ public partial class OrderService
chargeableWeight, chargeableWeight,
zoneKeys, zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General, order.CargoAttribute ?? CargoAttributeCodes.General,
customerGroup, chargingStage,
chargingStage); order.WarehouseId,
warehouseName);
if (previewFees.Count == 0) if (previewFees.Count == 0)
{ {
continue; continue;
@ -525,7 +683,8 @@ public partial class OrderService
{ {
Provider = provider, Provider = provider,
Channel = channel, Channel = channel,
TotalPayableCost = previewFees.Sum(x => x.Amount) TotalPayableCost = previewFees.Sum(x => x.Amount),
PayableFees = previewFees
}); });
} }
@ -534,34 +693,56 @@ public partial class OrderService
throw new BusinessException("排货规则未匹配到有效渠道买价"); throw new BusinessException("排货规则未匹配到有效渠道买价");
} }
var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates); var selectedCandidate = AllocationResolver.SelectCandidate(scheme, candidates);
var selectedCandidate = candidates.First(x => x.Channel.Id == selectedChannel.Id); var payableFees = selectedCandidate.PayableFees.ToList();
var channelPriceBindingFinal = await ResolveChannelPriceBindingAsync(selectedChannel.Id, bindTime); if (payableFees.Count == 0)
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("渠道买价未配置或未匹配到有效价格"); throw new BusinessException("渠道买价未配置或未匹配到有效价格");
} }
order.ChannelCode = selectedChannel.ChannelCode; order.ChannelCode = selectedCandidate.Channel.ChannelCode;
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder( AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
order, order,
selectedCandidate.Provider, selectedCandidate.Provider,
selectedChannel); selectedCandidate.Channel);
if (payableFees.Count > 0)
{
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage); await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
await OrderRepository.UpsertFeesAsync(order.Id, payableFees); await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
order.AllocationPendingReason = string.Empty;
}
private Task ConfirmServiceChannelAndPayableAsync(
Order order,
SalesProduct product,
OrderBillingContext billing,
string chargingStage) =>
ConfirmServiceChannelAndPayableAsync(
order,
product,
billing.SnapshotBinding,
billing.ZoneKeys,
billing.ChargeableWeight,
chargingStage,
billing.CustomerGroup);
/// <summary>
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
/// </summary>
private async Task<bool> TryConfirmServiceChannelAndPayableAsync(
Order order,
SalesProduct product,
OrderBillingContext billing,
string chargingStage)
{
try
{
await ConfirmServiceChannelAndPayableAsync(order, product, billing, chargingStage);
return true;
}
catch (BusinessException ex)
{
var message = ex.Message.Trim();
order.AllocationPendingReason = message.Length > 512 ? message[..512] : message;
return false;
} }
} }
@ -949,39 +1130,15 @@ public partial class OrderService
throw new BusinessException("未配置计重进位"); throw new BusinessException("未配置计重进位");
} }
var snapshotRanges = rulesSnapshot?.WeightRoundingInfos; var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
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); var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
if (dbRanges.Count == 0)
{
throw;
}
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges); forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
} rawWeight,
ranges,
rulesSnapshot,
weightRoundingId.Value,
dbRanges);
} }
catch (BusinessException) catch (BusinessException)
{ {
@ -993,6 +1150,135 @@ public partial class OrderService
} }
} }
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 async Task ApplyStageReceivableBillingAsync(
Order order,
SalesProduct product,
OrderWeight weightRow,
ProductSnapshotBinding snapshotBinding,
string chargingStage)
{
var billing = await ResolveOrderBillingContextAsync(
order,
product,
weightRow.ChargeableWeight ?? 0,
snapshotBinding);
await UpsertReceivableFeesAsync(
order,
billing,
chargingStage,
replaceExistingReceivable: true);
}
private static int? ResolveEffectiveWeightRuleId( private static int? ResolveEffectiveWeightRuleId(
SalesProduct product, SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot) SalesProductRulesPublishSnapshotDto? rulesSnapshot)
@ -1129,6 +1415,7 @@ public partial class OrderService
IsIntercept = 0, IsIntercept = 0,
IsRemote = 0, IsRemote = 0,
PredeclareFailReason = string.Empty, PredeclareFailReason = string.Empty,
AllocationPendingReason = string.Empty,
SalesProductRulesVersion = snapshotBinding.RulesVersion, SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion, SalesProductPriceVersion = snapshotBinding.PriceVersion,
LabelTemplateId = product.LabelTemplateId, LabelTemplateId = product.LabelTemplateId,
@ -1243,7 +1530,7 @@ public partial class OrderService
{ {
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
{ {
var generated = $"{ParcelServiceNoPrefix}{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}"; var generated = BuildParcelServiceNo(DateTime.Now, Random.Shared.Next(1000, 9999));
if (!await OrderRepository.HasServiceNoAsync(generated)) if (!await OrderRepository.HasServiceNoAsync(generated))
{ {
return generated; return generated;
@ -1276,11 +1563,7 @@ public partial class OrderService
throw new BusinessException("运单号不能为空"); throw new BusinessException("运单号不能为空");
} }
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "收货");
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
}
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim()); var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null) if (order == null)
@ -1293,10 +1576,7 @@ public partial class OrderService
throw new BusinessException("订单未绑定销售产品,无法入库计费"); throw new BusinessException("订单未绑定销售产品,无法入库计费");
} }
if (order.OrderStatus is not (OrderStatusCodes.Predeclared or OrderStatusCodes.Received)) ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
{
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许入库收货");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null) if (product == null)
@ -1304,6 +1584,8 @@ public partial class OrderService
throw new BusinessException("订单关联的销售产品不存在"); throw new BusinessException("订单关联的销售产品不存在");
} }
EnsureBillingProductActive(product);
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id) .OrderBy(x => x.Id)
.ToList(); .ToList();
@ -1313,55 +1595,33 @@ public partial class OrderService
throw new BusinessException("订单缺少收货件重尺记录"); throw new BusinessException("订单缺少收货件重尺记录");
} }
receive.Length = length; ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
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); var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights); await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); var billing = await ResolveOrderBillingContextAsync(
var recipient = recipients.FirstOrDefault(); order,
var zoneKeys = await ResolvePriceZoneKeysAsync(
product, product,
snapshotBinding.Rules, receive.ChargeableWeight ?? 0,
snapshotBinding.Price, snapshotBinding);
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( await UpsertReceivableFeesAsync(
order, order,
snapshotBinding, billing,
receive.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Inbound, OrderFeeChargingStageCodes.Inbound,
replaceExistingReceivable: true); replaceExistingReceivable: true);
await ConfirmServiceChannelAndPayableAsync( var allocated = await TryConfirmServiceChannelAndPayableAsync(
order, order,
product, product,
snapshotBinding, billing,
zoneKeys, OrderFeeChargingStageCodes.Inbound);
receive.ChargeableWeight ?? 0,
OrderFeeChargingStageCodes.Inbound,
customer?.CustomerGroup);
order.OrderStatus = OrderStatusCodes.PendingPayment; order.OrderStatus = allocated
? OrderStatusCodes.PendingPayment
: OrderStatusCodes.PendingAllocation;
order.ReceiveTime = DateTime.Now; order.ReceiveTime = DateTime.Now;
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
@ -1382,11 +1642,7 @@ public partial class OrderService
throw new BusinessException("运单号不能为空"); throw new BusinessException("运单号不能为空");
} }
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "发货");
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
throw new BusinessException("发货实重与长宽高不能为空且须大于 0");
}
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim()); var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null) if (order == null)
@ -1421,6 +1677,8 @@ public partial class OrderService
throw new BusinessException("订单关联的销售产品不存在"); throw new BusinessException("订单关联的销售产品不存在");
} }
EnsureBillingProductActive(product);
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id) .OrderBy(x => x.Id)
.ToList(); .ToList();
@ -1430,44 +1688,18 @@ public partial class OrderService
throw new BusinessException("订单缺少发货件重尺记录"); throw new BusinessException("订单缺少发货件重尺记录");
} }
ship.Length = length; ApplyMeasuredDimensions(ship, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
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); var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights); await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); await ApplyStageReceivableBillingAsync(
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, order,
product,
ship,
snapshotBinding, snapshotBinding,
ship.ChargeableWeight ?? 0, OrderFeeChargingStageCodes.Outbound);
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Outbound,
replaceExistingReceivable: true);
var now = DateTime.Now; var now = DateTime.Now;
order.OrderStatus = OrderStatusCodes.PendingOnline; order.OrderStatus = OrderStatusCodes.PendingOnline;

View File

@ -2,6 +2,8 @@ using Fake.Application;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared.Enums; using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Entities;
@ -97,34 +99,62 @@ public partial class OrderService
}; };
} }
private sealed record QuoteZoneAttempt(
IReadOnlyList<string> ZoneKeys,
bool IsZoneFallback,
string? ZoneName);
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input) private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
{ {
ValidatePriceCheckInput(input); ValidatePriceCheckInput(input);
var profile = PriceCheckInputProfile.FromInput(input);
var bindTime = DateTime.Now; var bindTime = DateTime.Now;
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry); var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
var cargoTargets = ResolvePriceCheckCargoTargets(input); var cargoTargets = ResolvePriceCheckCargoTargets(input);
var warehouses = await ResolvePriceCheckWarehousesAsync(input); var warehouses = await ResolvePriceCheckWarehousesAsync(input);
var dimensionLength = profile.HasDimensions ? input.Length!.Value : 0M;
var dimensionWidth = profile.HasDimensions ? input.Width!.Value : 0M;
var dimensionHeight = profile.HasDimensions ? input.Height!.Value : 0M;
var baseContext = new ParcelQuoteContext var baseContext = new ParcelQuoteContext
{ {
CountryCode = countryCode, CountryCode = countryCode,
Province = input.DestinationProvince, Province = input.DestinationProvince,
PostalCode = input.PostalCode, PostalCode = input.PostalCode,
Length = input.Length!.Value, Length = dimensionLength,
Width = input.Width!.Value, Width = dimensionWidth,
Height = input.Height!.Value, Height = dimensionHeight,
ActualWeight = input.CargoWeight!.Value ActualWeight = input.CargoWeight!.Value
}; };
var customerGroup = await ResolveCurrentCustomerGroupAsync(); var customerGroup = await ResolveCurrentCustomerGroupAsync();
var shippingKeyword = input.ShippingMethod?.Trim(); var shippingKeyword = input.ShippingMethod?.Trim();
var products = await SalesProductRepository.GetListAsync(x => List<SalesProduct> products;
if (string.IsNullOrWhiteSpace(shippingKeyword))
{
products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
}
else
{
var exact = await SalesProductRepository.GetByProductCodeAsync(shippingKeyword);
if (exact != null && exact.IsActive == EnableStatus.Enabled)
{
products = [exact];
}
else
{
products = await SalesProductRepository.GetListAsync(x =>
x.IsActive == EnableStatus.Enabled && x.IsActive == EnableStatus.Enabled &&
(string.IsNullOrWhiteSpace(shippingKeyword) (x.ProductName.Contains(shippingKeyword) ||
|| x.ProductName.Contains(shippingKeyword) x.ProductCode.Contains(shippingKeyword)));
|| x.ProductCode.Contains(shippingKeyword))); }
}
var result = new OrderPriceCheckResultOutput(); var result = new OrderPriceCheckResultOutput
{
QueryHint = PriceCheckHintBuilder.BuildQueryHint(profile)
};
if (products.Count == 0) if (products.Count == 0)
{ {
result.Failures.Add(new OrderPriceCheckFailureOutput result.Failures.Add(new OrderPriceCheckFailureOutput
@ -161,6 +191,7 @@ public partial class OrderService
var attempt = await TryComputePriceCheckQuoteAsync( var attempt = await TryComputePriceCheckQuoteAsync(
product, product,
quoteContext, quoteContext,
profile,
customerGroup, customerGroup,
bindTime, bindTime,
cargo.DisplayName, cargo.DisplayName,
@ -170,6 +201,7 @@ public partial class OrderService
{ {
var quote = attempt.Quote!; var quote = attempt.Quote!;
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines); var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
result.Items.Add(new OrderPriceCheckOutput result.Items.Add(new OrderPriceCheckOutput
{ {
Id = quote.ProductId, Id = quote.ProductId,
@ -191,7 +223,11 @@ public partial class OrderService
TotalCostNum = summary.TotalCost, TotalCostNum = summary.TotalCost,
RulesVersion = quote.RulesVersion, RulesVersion = quote.RulesVersion,
PriceVersion = quote.PriceVersion, PriceVersion = quote.PriceVersion,
IsRemote = quote.IsRemote IsRemote = quote.IsRemote,
QuoteLevel = itemHint.QuoteLevel,
MissingFieldCodes = itemHint.MissingFieldCodes,
Disclaimers = itemHint.Disclaimers,
ZoneName = quote.ZoneName
}); });
continue; continue;
} }
@ -222,7 +258,12 @@ public partial class OrderService
if (codes.Count == 0) if (codes.Count == 0)
{ {
codes.Add(string.Empty); return
[
BuildCargoTarget(CargoAttributeCodes.General),
BuildCargoTarget(CargoAttributeCodes.Electronics),
BuildCargoTarget(CargoAttributeCodes.Sensitive)
];
} }
return codes.Select(code => return codes.Select(code =>
@ -233,6 +274,12 @@ public partial class OrderService
}).ToList(); }).ToList();
} }
private static PriceCheckCargoTarget BuildCargoTarget(string cargoAttribute) =>
new(
cargoAttribute,
ParcelBillingCalculator.GetCargoAttributeLabel(cargoAttribute),
cargoAttribute);
private async Task<List<WarehouseAddress>> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input) private async Task<List<WarehouseAddress>> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input)
{ {
var ids = (input.WarehouseIds ?? []) var ids = (input.WarehouseIds ?? [])
@ -262,11 +309,7 @@ public partial class OrderService
throw new BusinessException("货物重量必须大于0"); throw new BusinessException("货物重量必须大于0");
} }
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue ParcelBillingValidators.EnsurePriceCheckDimensions(input.Length, input.Width, input.Height);
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
{
throw new BusinessException("长宽高必须大于0");
}
} }
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry) private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
@ -337,6 +380,7 @@ public partial class OrderService
private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync( private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
SalesProduct product, SalesProduct product,
ParcelQuoteContext context, ParcelQuoteContext context,
PriceCheckInputProfile profile,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
DateTime bindTime, DateTime bindTime,
string cargoTypeName, string cargoTypeName,
@ -357,7 +401,8 @@ public partial class OrderService
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName); return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
} }
if (await IsBlacklistHitAsync( if (profile.HasPostalCode
&& await IsBlacklistHitAsync(
product, product,
snapshotBinding.Rules, snapshotBinding.Rules,
context.CountryCode, context.CountryCode,
@ -373,89 +418,71 @@ public partial class OrderService
warehouseName); warehouseName);
} }
var forecast = ParcelBillingCalculator.BuildForecastWeight(context); OrderWeight forecast;
try try
{ {
if (profile.HasDimensions)
{
forecast = ParcelBillingCalculator.BuildForecastWeight(context);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast); await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
} }
else
{
forecast = new OrderWeight
{
Type = OrderWeightTypeCodes.Forecast,
ActualWeight = context.ActualWeight
};
await ApplyQuoteChargeableWeightFromActualOnlyAsync(product, snapshotBinding.Rules, forecast);
}
}
catch (BusinessException ex) catch (BusinessException ex)
{ {
return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName); return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName);
} }
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price); var zoneAttempts = await BuildQuoteZoneAttemptsAsync(product, snapshotBinding, context, profile);
if (!priceAreaId.HasValue) if (zoneAttempts.Count == 0)
{ {
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
var snapshotZoneHint = snapshotZones.Count > 0
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}],"
: string.Empty;
return PriceCheckFailure( return PriceCheckFailure(
product, product,
"zone", "zone",
$"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键", profile.HasPostalCode
cargoTypeName, ? $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区"
warehouseName); : $"目的国 {context.CountryCode} 未匹配到可参考的价格分区",
}
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
context.CountryCode,
context.Province,
context.City,
context.PostalCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
var snapshotZoneHint = snapshotZones.Count > 0
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}]。"
: string.Empty;
return PriceCheckFailure(
product,
"zone",
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," +
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致",
cargoTypeName, cargoTypeName,
warehouseName); warehouseName);
} }
var chargeableWeight = forecast.ChargeableWeight ?? 0; var chargeableWeight = forecast.ChargeableWeight ?? 0;
ParcelQuoteResult? bestQuote = null;
decimal bestTotal = decimal.MaxValue;
foreach (var zoneAttempt in zoneAttempts)
{
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines( var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
snapshotBinding.Price, snapshotBinding.Price,
snapshotBinding.PriceVersion, snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime, snapshotBinding.PriceEffectiveTime,
chargeableWeight, chargeableWeight,
zoneKeys, zoneAttempt.ZoneKeys,
context.CargoAttribute, context.CargoAttribute,
customerGroup, customerGroup,
context.WarehouseId, context.WarehouseId,
context.WarehouseName); context.WarehouseName);
if (feeLines.Count == 0) if (feeLines.Count == 0)
{ {
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price)) continue;
}
var total = feeLines.Sum(x => x.Fee.Amount);
if (total >= bestTotal)
{ {
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName); continue;
} }
return PriceCheckFailure( var isRemote = profile.HasPostalCode
product, && await IsRemoteHitAsync(
"price",
ParcelBillingCalculator.ExplainQuotePriceMiss(
snapshotBinding.Price,
chargeableWeight,
zoneKeys,
context.CargoAttribute,
customerGroup,
context.WarehouseId,
context.WarehouseName),
cargoTypeName,
warehouseName);
}
var isRemote = await IsRemoteHitAsync(
product, product,
snapshotBinding.Rules, snapshotBinding.Rules,
context.CountryCode, context.CountryCode,
@ -463,9 +490,8 @@ public partial class OrderService
context.City, context.City,
context.PostalCode); context.PostalCode);
return new PriceCheckAttemptResult bestTotal = total;
{ bestQuote = new ParcelQuoteResult
Quote = new ParcelQuoteResult
{ {
ProductId = product.Id, ProductId = product.Id,
ProductCode = product.ProductCode, ProductCode = product.ProductCode,
@ -474,13 +500,94 @@ public partial class OrderService
PriceVersion = snapshotBinding.PriceVersion, PriceVersion = snapshotBinding.PriceVersion,
VolumetricWeight = forecast.VolumetricWeight ?? 0, VolumetricWeight = forecast.VolumetricWeight ?? 0,
ChargeableWeight = chargeableWeight, ChargeableWeight = chargeableWeight,
ZoneName = zoneKeys[0], ZoneName = zoneAttempt.ZoneName ?? zoneAttempt.ZoneKeys.FirstOrDefault(),
IsRemote = isRemote, IsRemote = isRemote,
UsedZoneFallback = zoneAttempt.IsZoneFallback,
FeeLines = feeLines FeeLines = feeLines
}
}; };
} }
if (bestQuote == null)
{
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
{
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
}
var fallbackZoneKeys = zoneAttempts[0].ZoneKeys;
return PriceCheckFailure(
product,
"price",
ParcelBillingCalculator.ExplainQuotePriceMiss(
snapshotBinding.Price,
chargeableWeight,
fallbackZoneKeys,
context.CargoAttribute,
customerGroup,
context.WarehouseId,
context.WarehouseName),
cargoTypeName,
warehouseName);
}
return new PriceCheckAttemptResult { Quote = bestQuote };
}
private async Task<List<QuoteZoneAttempt>> BuildQuoteZoneAttemptsAsync(
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
ParcelQuoteContext context,
PriceCheckInputProfile profile)
{
var attempts = new List<QuoteZoneAttempt>();
async Task TryAddAsync(string? province, string? zipCode, bool isFallback)
{
var keys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
context.CountryCode,
province,
context.City,
zipCode);
if (keys is not { Count: > 0 })
{
return;
}
if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)))
{
return;
}
attempts.Add(new QuoteZoneAttempt(keys, isFallback, keys[0]));
}
if (profile.HasPostalCode || profile.HasProvince)
{
await TryAddAsync(
profile.HasProvince ? context.Province : null,
profile.HasPostalCode ? context.PostalCode : null,
false);
return attempts;
}
await TryAddAsync(null, null, false);
foreach (var zoneKey in ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price))
{
if (attempts.Any(x => x.ZoneKeys.Any(k => string.Equals(k, zoneKey, StringComparison.OrdinalIgnoreCase))))
{
continue;
}
attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey));
}
return attempts;
}
private static PriceCheckAttemptResult PriceCheckFailure( private static PriceCheckAttemptResult PriceCheckFailure(
SalesProduct product, SalesProduct product,
string step, string step,

View File

@ -347,6 +347,16 @@ internal static class ParcelBillingCalculator
RightPostcode = rule.RightPostcode RightPostcode = rule.RightPostcode
}; };
/// <summary>
/// 卖价/买价明细匹配共用上下文(分区、货物属性、计费重、仓库)。
/// </summary>
internal readonly record struct PriceDetailMatchContext(
IReadOnlyList<string> ZoneKeys,
string CargoAttribute,
decimal ChargeableWeight,
int? WarehouseId = null,
string? WarehouseName = null);
public static List<OrderFee> CalculateQuoteReceivableFees( public static List<OrderFee> CalculateQuoteReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot, SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion, string priceVersion,
@ -622,7 +632,7 @@ internal static class ParcelBillingCalculator
} }
/// <summary> /// <summary>
/// 应付费用:买价全部启用项参与;ChargingStage 由件重尺阶段决定。 /// 应付费用:买价全部启用项参与;渠道买价不区分客户组。
/// </summary> /// </summary>
public static List<OrderFee> CalculatePayableFees( public static List<OrderFee> CalculatePayableFees(
IReadOnlyList<ServiceChannelPriceListOutput> prices, IReadOnlyList<ServiceChannelPriceListOutput> prices,
@ -631,8 +641,9 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight, decimal chargeableWeight,
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup, string chargingStage,
string chargingStage) int? warehouseId = null,
string? warehouseName = null)
{ {
var items = prices.Select(p => new PayableBillingPriceItem( var items = prices.Select(p => new PayableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details)); p.Status, p.CostItem, p.Currency, p.Details));
@ -640,15 +651,12 @@ internal static class ParcelBillingCalculator
items, items,
priceVersion, priceVersion,
priceEffectiveTime, priceEffectiveTime,
chargeableWeight, new PriceDetailMatchContext(zoneKeys, cargoAttribute, chargeableWeight, warehouseId, warehouseName),
zoneKeys,
cargoAttribute,
customerGroup,
chargingStage); chargingStage);
} }
/// <summary> /// <summary>
/// 预报阶段应付费用(买价快照,计费重取 downstream 行,调用方传入对应重量)。 /// 预报阶段应付费用(买价快照)。
/// </summary> /// </summary>
public static List<OrderFee> CalculateForecastPayableFees( public static List<OrderFee> CalculateForecastPayableFees(
IReadOnlyList<ServiceChannelPriceListOutput> prices, IReadOnlyList<ServiceChannelPriceListOutput> prices,
@ -657,7 +665,8 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight, decimal chargeableWeight,
string zoneName, string zoneName,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup) => int? warehouseId = null,
string? warehouseName = null) =>
CalculatePayableFees( CalculatePayableFees(
prices, prices,
priceVersion, priceVersion,
@ -665,8 +674,9 @@ internal static class ParcelBillingCalculator
chargeableWeight, chargeableWeight,
new[] { zoneName }, new[] { zoneName },
cargoAttribute, cargoAttribute,
customerGroup, OrderFeeChargingStageCodes.Forecast,
OrderFeeChargingStageCodes.Forecast); warehouseId,
warehouseName);
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot); HasEnabledReceivablePriceItems(priceSnapshot);
@ -803,8 +813,14 @@ internal static class ParcelBillingCalculator
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
string chargingStage, string chargingStage,
int? warehouseId = null, int? warehouseId = null,
string? warehouseName = null) => string? warehouseName = null)
CalculateReceivableFeeLines( {
if (chargeableWeight <= 0)
{
return [];
}
return CalculateReceivableFeeLines(
priceItems, priceItems,
priceVersion, priceVersion,
priceEffectiveTime, priceEffectiveTime,
@ -815,6 +831,7 @@ internal static class ParcelBillingCalculator
chargingStage, chargingStage,
warehouseId, warehouseId,
warehouseName).Select(x => x.Fee).ToList(); warehouseName).Select(x => x.Fee).ToList();
}
private static List<BillingFeeLine> CalculateReceivableFeeLines( private static List<BillingFeeLine> CalculateReceivableFeeLines(
IEnumerable<ReceivableBillingPriceItem> priceItems, IEnumerable<ReceivableBillingPriceItem> priceItems,
@ -885,38 +902,35 @@ internal static class ParcelBillingCalculator
IEnumerable<PayableBillingPriceItem> priceItems, IEnumerable<PayableBillingPriceItem> priceItems,
string priceVersion, string priceVersion,
DateTime priceEffectiveTime, DateTime priceEffectiveTime,
decimal chargeableWeight, PriceDetailMatchContext match,
IReadOnlyList<string> zoneKeys, string chargingStage)
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{ {
if (match.ChargeableWeight <= 0)
{
return [];
}
var fees = new List<OrderFee>(); var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status))) foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{ {
var detail = MatchPriceDetail( var detail = MatchPriceDetail(
priceItem.Details, priceItem.Details,
zoneKeys, match.ZoneKeys,
cargoAttribute, match.CargoAttribute,
chargeableWeight, match.ChargeableWeight,
warehouseId, match.WarehouseId,
warehouseName); match.WarehouseName);
if (detail == null) if (detail == null)
{ {
continue; continue;
} }
var amount = CalculateDetailAmount( var amount = CalculatePayableDetailAmount(priceItem.Details, detail, match);
priceItem.Details, if (amount <= 0)
detail, {
zoneKeys, continue;
cargoAttribute, }
chargeableWeight,
customerGroup,
warehouseId,
warehouseName);
fees.Add(new OrderFee fees.Add(new OrderFee
{ {
Type = OrderFeeTypeCodes.Payable, Type = OrderFeeTypeCodes.Payable,
@ -932,6 +946,118 @@ internal static class ParcelBillingCalculator
return fees; return fees;
} }
/// <summary>
/// 渠道买价明细金额:与下游服务商结算,仅使用 CalculatedValue / 续重配置,不区分客户组。
/// </summary>
private static decimal CalculatePayableDetailAmount(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
PriceDetailMatchContext match)
{
return NormalizeCalculationFormula(detail.CalculationFormula) switch
{
"unit" => CalculateBuyUnitPrice(detail, match.ChargeableWeight),
"total" => ResolveBuyPrice(detail),
"continue" => CalculateBuyContinueAmount(allDetails, detail, match),
_ => ResolveBuyPrice(detail)
};
}
private static decimal ResolveBuyPrice(ChannelPriceDetailOutput detail) =>
detail.CalculatedValue ?? 0;
private static decimal CalculateBuyUnitPrice(ChannelPriceDetailOutput detail, decimal chargeableWeight)
{
var unitPrice = ResolveBuyPrice(detail);
return Math.Round(chargeableWeight * unitPrice, 4, MidpointRounding.AwayFromZero);
}
private static decimal CalculateBuyContinueAmount(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
ChannelPriceDetailOutput detail,
PriceDetailMatchContext match)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (TryGetWeightFactorText(factors, out var weightText)
&& TryParseWeightInterval(weightText, out var rangeStart, out _)
&& TryFindAdjacentBuyTotalBasePrice(allDetails, match, rangeStart, out var basePrice))
{
if (match.ChargeableWeight <= rangeStart)
{
return Math.Round(basePrice, 4, MidpointRounding.AwayFromZero);
}
var continueUnitPrice = ResolveBuyPrice(detail);
return Math.Round(
basePrice + (match.ChargeableWeight - rangeStart) * continueUnitPrice,
4,
MidpointRounding.AwayFromZero);
}
return CalculateBuyContinuePrice(detail, match.ChargeableWeight);
}
private static bool TryFindAdjacentBuyTotalBasePrice(
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
PriceDetailMatchContext match,
decimal continueRangeStart,
out decimal basePrice)
{
basePrice = 0;
foreach (var candidate in allDetails)
{
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
if (!ZoneMatches(factors, match.ZoneKeys)
|| !MatchCargoTypeFactor(factors, match.CargoAttribute)
|| !MatchWarehouseFactor(factors, match.WarehouseId, match.WarehouseName))
{
continue;
}
if (NormalizeCalculationFormula(candidate.CalculationFormula) != "total")
{
continue;
}
if (!TryGetWeightFactorText(factors, out var weightText)
|| !TryParseWeightInterval(weightText, out _, out var rangeEnd))
{
continue;
}
if (rangeEnd != continueRangeStart)
{
continue;
}
basePrice = ResolveBuyPrice(candidate);
return true;
}
return false;
}
private static decimal CalculateBuyContinuePrice(
ChannelPriceDetailOutput detail,
decimal chargeableWeight)
{
var factors = ParseFactorDictionary(detail.FactorValuesJson);
var continuePricing = TryReadContinuePricing(factors, detail);
var firstWeight = continuePricing?.FirstWeight ?? 1M;
var firstPrice = continuePricing?.FirstPrice ?? ResolveBuyPrice(detail);
var continueUnit = continuePricing?.ContinueUnit ?? 0.5M;
var continuePrice = continuePricing?.ContinuePrice ?? firstPrice;
if (chargeableWeight <= firstWeight)
{
return Math.Round(firstPrice, 4, MidpointRounding.AwayFromZero);
}
var overflow = chargeableWeight - firstWeight;
var continueUnits = Math.Ceiling(overflow / continueUnit);
return Math.Round(firstPrice + continueUnits * continuePrice, 4, MidpointRounding.AwayFromZero);
}
private readonly record struct ReceivableBillingPriceItem( private readonly record struct ReceivableBillingPriceItem(
byte Status, byte Status,
string CostItem, string CostItem,

View File

@ -0,0 +1,64 @@
namespace Oms.Application.Services.Orders;
using Fake.Application;
using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
/// <summary>
/// 小包计费各环节共用的件重尺校验。
/// </summary>
internal static class ParcelBillingValidators
{
/// <summary>
/// 预报/入库/出库:长宽高与实重均须 &gt; 0。
/// </summary>
public static void EnsurePositiveDimensions(
decimal? length,
decimal? width,
decimal? height,
decimal? actualWeight,
string stageLabel)
{
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
throw new BusinessException($"{stageLabel}实重与长宽高不能为空且须大于 0");
}
}
/// <summary>
/// 查价:尺寸要么全空(按实重估算),要么三者齐全。
/// </summary>
public static void EnsurePriceCheckDimensions(
decimal? length,
decimal? width,
decimal? height)
{
var hasPartial = length is > 0 || width is > 0 || height is > 0;
var hasFull = length is > 0 && width is > 0 && height is > 0;
if (hasPartial && !hasFull)
{
throw new BusinessException("尺寸请填写完整的长、宽、高,或全部留空按实重估算");
}
}
public static bool HasFullDimensions(decimal? length, decimal? width, decimal? height) =>
length is > 0 && width is > 0 && height is > 0;
public static void EnsureInboundOrderStatus(string? orderStatus)
{
if (orderStatus is OrderStatusCodes.Predeclared or OrderStatusCodes.PendingAllocation)
{
return;
}
if (orderStatus is OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip
or OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed)
{
throw new BusinessException("订单已入库,无需重复收货");
}
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(orderStatus)}」,不允许入库收货");
}
}

View File

@ -25,5 +25,7 @@ internal sealed class ParcelQuoteResult
public bool IsRemote { get; init; } public bool IsRemote { get; init; }
public bool UsedZoneFallback { get; init; }
public IReadOnlyList<BillingFeeLine> FeeLines { get; init; } = []; public IReadOnlyList<BillingFeeLine> FeeLines { get; init; } = [];
} }

View File

@ -0,0 +1,77 @@
using Oms.Application.Dtos.Orders;
namespace Oms.Application.Services.Orders;
internal static class PriceCheckHintBuilder
{
public static OrderPriceCheckHintOutput BuildQueryHint(PriceCheckInputProfile profile)
{
var missing = profile.MissingFieldCodes.ToList();
var disclaimers = BuildDisclaimers(missing);
if (profile.IsQueryEstimated)
{
disclaimers.Add("以下报价可能含估算项,最终以预报/入库计费为准。");
}
return new OrderPriceCheckHintOutput
{
QuoteLevel = profile.IsQueryEstimated
? PriceCheckQuoteLevelCodes.Estimated
: PriceCheckQuoteLevelCodes.Exact,
MissingFieldCodes = missing,
Disclaimers = disclaimers
};
}
public static (string QuoteLevel, List<string> MissingFieldCodes, List<string> Disclaimers) BuildItemHint(
PriceCheckInputProfile profile,
bool usedZoneFallback)
{
var missing = profile.MissingFieldCodes.ToList();
if (usedZoneFallback && !missing.Contains(PriceCheckMissingFieldCodes.PostalCode))
{
missing.Add(PriceCheckMissingFieldCodes.PostalCode);
}
var disclaimers = BuildDisclaimers(missing);
if (usedZoneFallback)
{
disclaimers.Add("未匹配到精确分区,本行按可匹配分区参考价展示,下单后可能调整。");
}
var estimated = missing.Count > 0 || usedZoneFallback;
return (
estimated ? PriceCheckQuoteLevelCodes.Estimated : PriceCheckQuoteLevelCodes.Exact,
missing,
disclaimers);
}
private static List<string> BuildDisclaimers(IReadOnlyList<string> missingFieldCodes)
{
var disclaimers = new List<string>();
foreach (var code in missingFieldCodes)
{
var text = code switch
{
PriceCheckMissingFieldCodes.Dimensions =>
"未填写包裹尺寸:计费重按实重估算,入库实测尺寸后价格可能变化。",
PriceCheckMissingFieldCodes.PostalCode =>
"未填写邮编:未校验黑名单/偏远/精确分区,报价仅供参考。",
PriceCheckMissingFieldCodes.Province =>
"未填写省州:分区按国家级匹配,精确地址可能调整价格。",
PriceCheckMissingFieldCodes.CargoType =>
"未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。",
PriceCheckMissingFieldCodes.Warehouse =>
"未选择仓库:展示各仓库报价,下单时请确认发货仓。",
_ => null
};
if (!string.IsNullOrWhiteSpace(text))
{
disclaimers.Add(text);
}
}
return disclaimers;
}
}

View File

@ -0,0 +1,93 @@
namespace Oms.Application.Services.Orders;
using Oms.Application.Dtos.Orders;
/// <summary>
/// 查价入参完整度(用于估算逻辑与对客提示)。
/// </summary>
internal sealed class PriceCheckInputProfile
{
public bool HasDimensions { get; init; }
public bool HasPostalCode { get; init; }
public bool HasProvince { get; init; }
public bool HasCargoTypeFilter { get; init; }
public bool HasWarehouseFilter { get; init; }
public bool HasShippingMethodFilter { get; init; }
public IReadOnlyList<string> MissingFieldCodes { get; init; } = [];
public static PriceCheckInputProfile FromInput(OrderPriceCheckInput input)
{
var hasDimensions = input.Length is > 0 && input.Width is > 0 && input.Height is > 0;
var hasPostal = !string.IsNullOrWhiteSpace(input.PostalCode);
var hasProvince = !string.IsNullOrWhiteSpace(input.DestinationProvince);
var cargoCodes = (input.CargoTypes ?? [])
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
if (cargoCodes.Count == 0 && !string.IsNullOrWhiteSpace(input.CargoType))
{
cargoCodes.Add(input.CargoType.Trim());
}
var hasCargoFilter = cargoCodes.Count > 0;
var hasWarehouseFilter = (input.WarehouseIds ?? []).Any(x => x > 0);
var hasShippingFilter = !string.IsNullOrWhiteSpace(input.ShippingMethod);
var missing = new List<string>();
if (!hasDimensions)
{
missing.Add(PriceCheckMissingFieldCodes.Dimensions);
}
if (!hasPostal)
{
missing.Add(PriceCheckMissingFieldCodes.PostalCode);
}
if (!hasProvince)
{
missing.Add(PriceCheckMissingFieldCodes.Province);
}
if (!hasCargoFilter)
{
missing.Add(PriceCheckMissingFieldCodes.CargoType);
}
if (!hasWarehouseFilter)
{
missing.Add(PriceCheckMissingFieldCodes.Warehouse);
}
return new PriceCheckInputProfile
{
HasDimensions = hasDimensions,
HasPostalCode = hasPostal,
HasProvince = hasProvince,
HasCargoTypeFilter = hasCargoFilter,
HasWarehouseFilter = hasWarehouseFilter,
HasShippingMethodFilter = hasShippingFilter,
MissingFieldCodes = missing
};
}
public bool IsQueryEstimated => MissingFieldCodes.Count > 0;
}
internal static class PriceCheckMissingFieldCodes
{
public const string Dimensions = "dimensions";
public const string PostalCode = "postalCode";
public const string Province = "province";
public const string CargoType = "cargoType";
public const string Warehouse = "warehouse";
}

View File

@ -1,6 +1,7 @@
using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Channels;
using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Shared; namespace Oms.Application.Shared;
@ -16,6 +17,9 @@ public static class AllocationResolver
public ServiceChannel Channel { get; init; } = null!; public ServiceChannel Channel { get; init; } = null!;
public decimal TotalPayableCost { get; init; } public decimal TotalPayableCost { get; init; }
/// <summary>候选渠道已算出的应付费用(选渠后可直接落库,避免重复算价)。</summary>
public IReadOnlyList<OrderFee> PayableFees { get; init; } = [];
} }
public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders( public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders(
@ -28,7 +32,10 @@ public static class AllocationResolver
.OrderBy(x => x.SerialNumber) .OrderBy(x => x.SerialNumber)
.ToList(); .ToList();
public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList<ChannelCandidate> candidates) public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList<ChannelCandidate> candidates) =>
SelectCandidate(scheme, candidates).Channel;
public static ChannelCandidate SelectCandidate(string? scheme, IReadOnlyList<ChannelCandidate> candidates)
{ {
if (candidates.Count == 0) if (candidates.Count == 0)
{ {
@ -44,18 +51,15 @@ public static class AllocationResolver
AllocationSchemeCodes.TimeFastest => candidates AllocationSchemeCodes.TimeFastest => candidates
.OrderBy(x => x.Channel.MinTime ?? int.MaxValue) .OrderBy(x => x.Channel.MinTime ?? int.MaxValue)
.ThenBy(x => x.Provider.SerialNumber) .ThenBy(x => x.Provider.SerialNumber)
.First() .First(),
.Channel,
AllocationSchemeCodes.OverallBest => candidates AllocationSchemeCodes.OverallBest => candidates
.OrderByDescending(x => x.Provider.Weight) .OrderByDescending(x => x.Provider.Weight)
.ThenBy(x => x.Provider.SerialNumber) .ThenBy(x => x.Provider.SerialNumber)
.First() .First(),
.Channel,
_ => candidates _ => candidates
.OrderBy(x => x.TotalPayableCost) .OrderBy(x => x.TotalPayableCost)
.ThenBy(x => x.Provider.SerialNumber) .ThenBy(x => x.Provider.SerialNumber)
.First() .First()
.Channel
}; };
} }
} }

View File

@ -160,6 +160,7 @@ public static class OmsDocHelper
OrderStatusCodes.Draft => "草稿", OrderStatusCodes.Draft => "草稿",
OrderStatusCodes.Predeclared => "已预报", OrderStatusCodes.Predeclared => "已预报",
OrderStatusCodes.Received => "已收货", OrderStatusCodes.Received => "已收货",
OrderStatusCodes.PendingAllocation => "待排货",
OrderStatusCodes.PendingPayment => "待支付", OrderStatusCodes.PendingPayment => "待支付",
OrderStatusCodes.PendingShip => "待发货", OrderStatusCodes.PendingShip => "待发货",
OrderStatusCodes.PendingOnline => "待上网", OrderStatusCodes.PendingOnline => "待上网",

View File

@ -5,6 +5,7 @@ public static class OrderStatusCodes
public const string Draft = "draft"; public const string Draft = "draft";
public const string Predeclared = "predeclared"; public const string Predeclared = "predeclared";
public const string Received = "received"; public const string Received = "received";
public const string PendingAllocation = "pendingAllocation";
public const string PendingPayment = "pendingPayment"; public const string PendingPayment = "pendingPayment";
public const string PendingShip = "pendingShip"; public const string PendingShip = "pendingShip";
public const string PendingOnline = "pendingOnline"; public const string PendingOnline = "pendingOnline";

View File

@ -48,6 +48,12 @@ public class Order : OmsAuditedAggregateRoot
public string? PredeclareFailReason { get; set; } public string? PredeclareFailReason { get; set; }
/// <summary>
/// 待排货原因:入库等环节排货或渠道买价未成功时写入,重算应付成功后清空。
/// </summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? AllocationPendingReason { get; set; }
public string? SalesProductRulesVersion { get; set; } public string? SalesProductRulesVersion { get; set; }
public string? SalesProductPriceVersion { get; set; } public string? SalesProductPriceVersion { get; set; }

View File

@ -1,6 +1,7 @@
using FluentAssertions; using FluentAssertions;
using Fake.Application; using Fake.Application;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq; using Moq;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Messaging; using Oms.Application.Messaging;
@ -23,11 +24,21 @@ public class OrderBatchJobCoordinatorGovernanceTests
public OrderBatchJobCoordinatorGovernanceTests() public OrderBatchJobCoordinatorGovernanceTests()
{ {
_publisher.SetupGet(x => x.IsEnabled).Returns(true); _publisher.SetupGet(x => x.IsEnabled).Returns(true);
var batchOptions = Options.Create(new OrderBatchRabbitMqOptions());
var actionExecutor = new Mock<IOrderBatchActionExecutor>(MockBehavior.Loose);
var processor = new OrderBatchJobProcessor(
_batchJobRepository.Object,
_orderRepository.Object,
actionExecutor.Object,
batchOptions,
NullLogger<OrderBatchJobProcessor>.Instance);
_coordinator = new OrderBatchJobCoordinator( _coordinator = new OrderBatchJobCoordinator(
_batchJobRepository.Object, _batchJobRepository.Object,
_orderRepository.Object, _orderRepository.Object,
_countryRepository.Object, _countryRepository.Object,
_publisher.Object, _publisher.Object,
processor,
batchOptions,
NullLogger<OrderBatchJobCoordinator>.Instance); NullLogger<OrderBatchJobCoordinator>.Instance);
} }

View File

@ -129,8 +129,7 @@ public class ParcelBillingCalculatorDemoTests
effectiveTime, effectiveTime,
chargeableWeight, chargeableWeight,
"美西", "美西",
quoteContext.CargoAttribute, quoteContext.CargoAttribute);
null);
var payableSummary = ParcelBillingCalculator.SummarizeFees(payableFees); var payableSummary = ParcelBillingCalculator.SummarizeFees(payableFees);

View File

@ -302,8 +302,7 @@ public class ParcelBillingCalculatorTests
_meta.EffectiveTime, _meta.EffectiveTime,
(decimal)chargeableWeight, (decimal)chargeableWeight,
Zone, Zone,
"general", "general");
null);
fees.Should().ContainSingle(); fees.Should().ContainSingle();
fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable); fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
@ -326,8 +325,7 @@ public class ParcelBillingCalculatorTests
_meta.EffectiveTime, _meta.EffectiveTime,
1.2M, 1.2M,
Zone, Zone,
"general", "general");
null);
fees.Should().BeEmpty(); fees.Should().BeEmpty();
ParcelBillingCalculator.HasEnabledPayablePriceItems(prices).Should().BeFalse(); ParcelBillingCalculator.HasEnabledPayablePriceItems(prices).Should().BeFalse();
@ -345,7 +343,7 @@ public class ParcelBillingCalculatorTests
var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees( var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null); snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
var payable = ParcelBillingCalculator.CalculateForecastPayableFees( var payable = ParcelBillingCalculator.CalculateForecastPayableFees(
payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null); payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general");
receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable); receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable); payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable);

View File

@ -21,7 +21,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{ {
Id = 1, Id = 1,
Status = 1, Status = 2,
CostItem = "freight", CostItem = "freight",
Details = Details =
[ [
@ -34,7 +34,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{ {
Id = 2, Id = 2,
Status = 1, Status = 2,
CostItem = "handling", CostItem = "handling",
Details = [Detail(28, 2, "total", "3", "0,10", 20)] Details = [Detail(28, 2, "total", "3", "0,10", 20)]
}) })
@ -98,7 +98,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{ {
Id = 1, Id = 1,
Status = 1, Status = 2,
CostItem = "freight", CostItem = "freight",
Details = Details =
[ [
@ -109,7 +109,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{ {
Id = 2, Id = 2,
Status = 1, Status = 2,
CostItem = "handling", CostItem = "handling",
Details = [Detail(44, 2, "total", "3", "(0-10]", 23)] Details = [Detail(44, 2, "total", "3", "(0-10]", 23)]
}) })
@ -188,7 +188,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{ {
Id = 1, Id = 1,
Status = 1, Status = 2,
CostItem = "freight", CostItem = "freight",
Details = Details =
[ [
@ -199,7 +199,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{ {
Id = 2, Id = 2,
Status = 1, Status = 2,
CostItem = "handling", CostItem = "handling",
Details = [Detail(38, 2, "total", "3", "(0-10]", 20)] Details = [Detail(38, 2, "total", "3", "(0-10]", 20)]
}) })

View File

@ -29,7 +29,7 @@ internal static class ParcelBillingTestData
params ChannelPriceDetailOutput[] details) => params ChannelPriceDetailOutput[] details) =>
new() new()
{ {
Status = status, Status = status == 0 ? (byte)0 : (byte)2,
CostItem = costItem, CostItem = costItem,
Currency = "CNY", Currency = "CNY",
Details = details.ToList() Details = details.ToList()
@ -41,7 +41,7 @@ internal static class ParcelBillingTestData
params ChannelPriceDetailOutput[] details) => params ChannelPriceDetailOutput[] details) =>
new() new()
{ {
Status = status, Status = status == 0 ? (byte)0 : (byte)2,
CostItem = costItem, CostItem = costItem,
Currency = "CNY", Currency = "CNY",
Details = details.ToList() Details = details.ToList()