diff --git a/db.sql b/db.sql
index 48e3955..7655c82 100644
--- a/db.sql
+++ b/db.sql
@@ -114,6 +114,7 @@ CREATE TABLE `t_order` (
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
+ `AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)',
`SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本',
`SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本',
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',
diff --git a/sql/migrate_order_status_pending_allocation.sql b/sql/migrate_order_status_pending_allocation.sql
new file mode 100644
index 0000000..6eb6303
--- /dev/null
+++ b/sql/migrate_order_status_pending_allocation.sql
@@ -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';
diff --git a/sql/migrate_t_order_allocation_pending_reason.sql b/sql/migrate_t_order_allocation_pending_reason.sql
new file mode 100644
index 0000000..46f2c57
--- /dev/null
+++ b/sql/migrate_t_order_allocation_pending_reason.sql
@@ -0,0 +1,3 @@
+-- 待排货原因:入库排货/买价未成功时记录,便于运营补配置后重算应付
+ALTER TABLE `t_order`
+ ADD COLUMN `AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)' AFTER `PredeclareFailReason`;
diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
index 2ac4836..d956091 100644
--- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs
@@ -135,6 +135,11 @@ public class OrderOutput
///
public string? PredeclareFailReason { get; set; }
+ ///
+ /// 待排货原因(排货或渠道买价未匹配,空表示已排货或无待办)。
+ ///
+ public string? AllocationPendingReason { get; set; }
+
///
/// 申报币种。
///
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckHintOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckHintOutput.cs
new file mode 100644
index 0000000..fc2dd5f
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckHintOutput.cs
@@ -0,0 +1,32 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 查价提示:缺失条件与免责声明(避免客户误以为报价为最终价)。
+///
+public class OrderPriceCheckHintOutput
+{
+ ///
+ /// 报价精度:exact=条件较完整;estimated=含估算项,仅供参考。
+ ///
+ public string QuoteLevel { get; set; } = PriceCheckQuoteLevelCodes.Exact;
+
+ ///
+ /// 缺失或未指定的入参字段编码(dimensions/postalCode/cargoType/warehouse 等)。
+ ///
+ public List MissingFieldCodes { get; set; } = [];
+
+ ///
+ /// 面向客户的提示文案。
+ ///
+ public List Disclaimers { get; set; } = [];
+}
+
+///
+/// 查价报价精度。
+///
+public static class PriceCheckQuoteLevelCodes
+{
+ public const string Exact = "exact";
+
+ public const string Estimated = "estimated";
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs
index 1c619b4..f109715 100644
--- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs
@@ -104,4 +104,24 @@ public class OrderPriceCheckOutput
/// 是否偏远。
///
public bool IsRemote { get; set; }
+
+ ///
+ /// 报价精度:exact / estimated。
+ ///
+ public string QuoteLevel { get; set; } = PriceCheckQuoteLevelCodes.Exact;
+
+ ///
+ /// 本行报价涉及的缺失条件编码。
+ ///
+ public List MissingFieldCodes { get; set; } = [];
+
+ ///
+ /// 本行对客提示(如未填邮编、未填尺寸等)。
+ ///
+ public List Disclaimers { get; set; } = [];
+
+ ///
+ /// 命中的价格分区名称/键(展示用)。
+ ///
+ public string? ZoneName { get; set; }
}
diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckResultOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckResultOutput.cs
index aa49ad9..23ee7a5 100644
--- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckResultOutput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckResultOutput.cs
@@ -14,4 +14,9 @@ public class OrderPriceCheckResultOutput
/// 试算失败或未命中的产品及原因。
///
public List Failures { get; set; } = [];
+
+ ///
+ /// 本次查价入参完整度与全局免责声明。
+ ///
+ public OrderPriceCheckHintOutput? QueryHint { get; set; }
}
diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
index 77799e4..d5c4810 100644
--- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
+++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs
@@ -383,27 +383,91 @@ public partial class ServiceChannelService
private static List BuildPriceFactors(IEnumerable inputs, int? priceId = null)
{
- return inputs.Select(input => new ServiceChannelPriceFactor
- {
- PriceId = priceId ?? 0,
- SerialNumber = input.SerialNumber,
- Factor = input.Factor,
- FactorValues = OmsDocHelper.SerializeJson(input.FactorValues)
- }).ToList();
+ return inputs
+ .Where(input => !IsCustomerPricingFactor(input.Factor))
+ .Select(input => new ServiceChannelPriceFactor
+ {
+ PriceId = priceId ?? 0,
+ SerialNumber = input.SerialNumber,
+ Factor = input.Factor,
+ FactorValues = OmsDocHelper.SerializeJson(input.FactorValues)
+ }).ToList();
}
private static List BuildPriceDetails(IEnumerable inputs, int? priceId = null)
{
- return inputs.Select(input => new ServiceChannelPriceDetail
+ return inputs.Select(input =>
{
- PriceId = priceId ?? 0,
- FactorValuesJson = OmsDocHelper.SerializeJson(input.FactorValuesJson),
- CalculationFormula = input.CalculationFormula,
- CalculatedValue = input.CalculatedValue,
- CustomerGroupValues = OmsDocHelper.SerializeJson(input.CustomerGroupValues)
+ var calculatedValue = input.CalculatedValue;
+ if ((calculatedValue == null || calculatedValue == 0) && input.CustomerGroupValues != null)
+ {
+ calculatedValue = ExtractFirstCustomerGroupPrice(input.CustomerGroupValues);
+ }
+
+ return new ServiceChannelPriceDetail
+ {
+ PriceId = priceId ?? 0,
+ FactorValuesJson = OmsDocHelper.SerializeJson(input.FactorValuesJson),
+ CalculationFormula = input.CalculationFormula,
+ CalculatedValue = calculatedValue,
+ CustomerGroupValues = null
+ };
}).ToList();
}
+ ///
+ /// 渠道买价不含客户组因素;保存时丢弃客户组列,并将历史数据首价迁移到 CalculatedValue。
+ ///
+ 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>(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)
{
return new ServiceChannelPriceSaveInput
diff --git a/src/Oms.Application/Services/Operations/InboundRecordService.cs b/src/Oms.Application/Services/Operations/InboundRecordService.cs
index 22c5033..2dda5d6 100644
--- a/src/Oms.Application/Services/Operations/InboundRecordService.cs
+++ b/src/Oms.Application/Services/Operations/InboundRecordService.cs
@@ -95,11 +95,14 @@ public class InboundRecordService : ApplicationService
throw new BusinessException("未找到对应订单,请检查单号");
}
- if (!input.Weight.HasValue || !input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
- || input.Weight <= 0 || input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
- {
- throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
- }
+ ParcelBillingValidators.EnsurePositiveDimensions(
+ input.Length,
+ input.Width,
+ input.Height,
+ input.Weight,
+ "收货");
+
+ ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
int? salesProductId = null;
string? destinationCountry = order.ConsigneeCountry;
diff --git a/src/Oms.Application/Services/Orders/OrderBillingContext.cs b/src/Oms.Application/Services/Orders/OrderBillingContext.cs
new file mode 100644
index 0000000..e268bda
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderBillingContext.cs
@@ -0,0 +1,33 @@
+using Oms.Application.Dtos.Orders;
+using Oms.Domain.CustomersAggregate.Enums;
+
+namespace Oms.Application.Services.Orders;
+
+///
+/// 算价用的收件地址(国家 + 省市区邮编)。
+///
+internal readonly record struct OrderBillingAddress(
+ string CountryCode,
+ string? Province,
+ string? City,
+ string? ZipCode);
+
+///
+/// 订单单次算价上下文:快照、分区键、计费重、货类、客户组、仓库。
+///
+internal sealed class OrderBillingContext
+{
+ public required ProductSnapshotBinding SnapshotBinding { get; init; }
+
+ public required IReadOnlyList 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; }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index 14c7ff0..40fabab 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -149,6 +149,9 @@ public partial class OrderService
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
? null
: order.PredeclareFailReason.Trim(),
+ AllocationPendingReason = string.IsNullOrWhiteSpace(order.AllocationPendingReason)
+ ? null
+ : order.AllocationPendingReason.Trim(),
DeclaredCurrency = order.DeclaredCurrency,
DeclaredSum = order.DeclaredSum,
Quantity = order.Quantity,
diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
index b8febf9..63b583e 100644
--- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
@@ -331,32 +331,15 @@ public partial class OrderService
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
- var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
- var recipient = recipients.FirstOrDefault();
- var zoneKeys = await ResolvePriceZoneKeysAsync(
+ var billing = await ResolveOrderBillingContextAsync(
+ order,
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;
+ weightRow.ChargeableWeight ?? 0,
+ snapshotBinding);
await UpsertReceivableFeesAsync(
order,
- snapshotBinding,
- weightRow.ChargeableWeight ?? 0,
- zoneKeys,
- order.CargoAttribute ?? CargoAttributeCodes.General,
- customer?.CustomerGroup,
+ billing,
chargingStage,
replaceExistingReceivable: true);
@@ -385,34 +368,24 @@ public partial class OrderService
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
- var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
- var recipient = recipients.FirstOrDefault();
- var zoneKeys = await ResolvePriceZoneKeysAsync(
+ var billing = await ResolveOrderBillingContextAsync(
+ order,
product,
- snapshotBinding.Rules,
- snapshotBinding.Price,
- order.ConsigneeCountry ?? string.Empty,
- recipient?.Province,
- recipient?.City,
- recipient?.ZipCode);
- if (zoneKeys == null || zoneKeys.Count == 0)
- {
- throw new BusinessException("价格分区未匹配(规则快照)");
- }
+ weightRow.ChargeableWeight ?? 0,
+ snapshotBinding);
var (_, chargingStage) = ResolveReceivableBillingStage(order);
- var customer = order.CustomerId.HasValue
- ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
- : null;
await ConfirmServiceChannelAndPayableAsync(
order,
product,
- snapshotBinding,
- zoneKeys,
- weightRow.ChargeableWeight ?? 0,
- chargingStage,
- customer?.CustomerGroup);
+ billing,
+ chargingStage);
+
+ if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
+ {
+ order.OrderStatus = OrderStatusCodes.PendingPayment;
+ }
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
@@ -487,7 +460,7 @@ public partial class OrderService
private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) =>
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),
OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index bb94ad3..e06e475 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -128,41 +128,29 @@ public partial class OrderService
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
- var zoneKeys = await ResolvePriceZoneKeysAsync(
- product,
- snapshotBinding.Rules,
- snapshotBinding.Price,
- country.CountryCode,
- input.Receiver?.Province,
- input.Receiver?.City,
- input.Receiver?.ZipCode);
- if (zoneKeys == null || zoneKeys.Count == 0)
- {
- throw new BusinessException("价格分区未匹配(规则快照)");
- }
-
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
- await UpsertReceivableFeesAsync(
- order,
- snapshotBinding,
- forecast.ChargeableWeight ?? 0,
- zoneKeys,
- order.CargoAttribute ?? CargoAttributeCodes.General,
- customer?.CustomerGroup,
- OrderFeeChargingStageCodes.Forecast);
-
- var downstream = weights.First(x => x.Type == OrderWeightTypeCodes.Downstream);
- await ConfirmServiceChannelAndPayableAsync(
- order,
+ var billing = await ResolveBillingContextAsync(
product,
snapshotBinding,
- zoneKeys,
- downstream.ChargeableWeight ?? forecast.ChargeableWeight ?? 0,
- OrderFeeChargingStageCodes.Forecast,
+ new OrderBillingAddress(
+ country.CountryCode,
+ input.Receiver?.Province,
+ input.Receiver?.City,
+ input.Receiver?.ZipCode),
+ forecast.ChargeableWeight ?? 0,
+ order.CargoAttribute ?? CargoAttributeCodes.General,
+ order.WarehouseId,
customer?.CustomerGroup);
+ await UpsertReceivableFeesAsync(
+ order,
+ billing,
+ OrderFeeChargingStageCodes.Forecast);
+
+ // 创建订单不对客户暴露排货/买价:渠道与应付在入库等环节由运营侧处理。
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
+ order.AllocationPendingReason = string.Empty;
order.IsRemote = order.IsRemote;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
@@ -194,7 +182,13 @@ public partial class OrderService
}
}
- private const string ParcelServiceNoPrefix = "ID";
+ private const string ParcelServiceNoPrefix = "IDE";
+
+ ///
+ /// 自动生成运单号:IDE + yyMMddHHmmss + 4 位随机数(如 IDE2607221859123456)。
+ ///
+ private static string BuildParcelServiceNo(DateTime now, int randomSuffix) =>
+ $"{ParcelServiceNoPrefix}{now:yyMMddHHmmss}{randomSuffix}";
private static void NormalizeParcelCreateInput(OrderParcelCreateInput input)
{
@@ -202,6 +196,10 @@ public partial class OrderService
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)
@@ -234,8 +232,8 @@ public partial class OrderService
throw new BusinessException("仓库ID不能为空");
}
- if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue
- || input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0)
+ if (!ParcelBillingValidators.HasFullDimensions(input.Length, input.Width, input.Height)
+ || !input.ActualWeight.HasValue || input.ActualWeight <= 0)
{
throw new BusinessException("件重尺必须大于0");
}
@@ -437,6 +435,141 @@ public partial class OrderService
};
}
+ private async Task ResolveWarehouseNameAsync(int? warehouseId)
+ {
+ if (!warehouseId.HasValue || warehouseId.Value <= 0)
+ {
+ return null;
+ }
+
+ var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
+ return warehouse?.WarehouseName;
+ }
+
+ private async Task ResolveOrderBillingAddressAsync(Order order)
+ {
+ var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
+ var recipient = recipients.FirstOrDefault();
+ return new OrderBillingAddress(
+ order.ConsigneeCountry ?? string.Empty,
+ recipient?.Province,
+ recipient?.City,
+ recipient?.ZipCode);
+ }
+
+ private async Task> ResolveZoneKeysOrThrowAsync(
+ SalesProduct product,
+ ProductSnapshotBinding snapshotBinding,
+ OrderBillingAddress address)
+ {
+ var zoneKeys = await ResolvePriceZoneKeysAsync(
+ product,
+ snapshotBinding.Rules,
+ snapshotBinding.Price,
+ address.CountryCode,
+ address.Province,
+ address.City,
+ address.ZipCode);
+ if (zoneKeys == null || zoneKeys.Count == 0)
+ {
+ throw new BusinessException("价格分区未匹配(规则快照)");
+ }
+
+ return zoneKeys;
+ }
+
+ private async Task ResolveCustomerGroupAsync(int? customerId)
+ {
+ if (!customerId.HasValue || customerId.Value <= 0)
+ {
+ return null;
+ }
+
+ var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value);
+ return customer?.CustomerGroup;
+ }
+
+ ///
+ /// 解析算价上下文(快照 + 分区 + 仓库 + 客户组)。供下单前或仅有地址时使用。
+ ///
+ private async Task ResolveBillingContextAsync(
+ SalesProduct product,
+ ProductSnapshotBinding snapshotBinding,
+ OrderBillingAddress address,
+ decimal chargeableWeight,
+ string cargoAttribute,
+ int? warehouseId,
+ CustomerGroup? customerGroup = null)
+ {
+ if (chargeableWeight <= 0)
+ {
+ throw new BusinessException("计费重无效,无法计费");
+ }
+
+ var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, snapshotBinding, address);
+ var warehouseName = await ResolveWarehouseNameAsync(warehouseId);
+ return new OrderBillingContext
+ {
+ SnapshotBinding = snapshotBinding,
+ ZoneKeys = zoneKeys,
+ ChargeableWeight = chargeableWeight,
+ CargoAttribute = cargoAttribute,
+ CustomerGroup = customerGroup,
+ WarehouseId = warehouseId,
+ WarehouseName = warehouseName
+ };
+ }
+
+ ///
+ /// 解析已有订单的算价上下文(绑定订单快照版本)。
+ ///
+ private async Task ResolveOrderBillingContextAsync(
+ Order order,
+ SalesProduct product,
+ decimal chargeableWeight,
+ ProductSnapshotBinding? snapshotBinding = null)
+ {
+ if (chargeableWeight <= 0)
+ {
+ throw new BusinessException("计费重无效,无法计费");
+ }
+
+ var binding = snapshotBinding ?? await ResolveSnapshotBindingFromOrderAsync(product, order);
+ var address = await ResolveOrderBillingAddressAsync(order);
+ var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, binding, address);
+ var customerGroup = await ResolveCustomerGroupAsync(order.CustomerId);
+ var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
+ return new OrderBillingContext
+ {
+ SnapshotBinding = binding,
+ ZoneKeys = zoneKeys,
+ ChargeableWeight = chargeableWeight,
+ CargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General,
+ CustomerGroup = customerGroup,
+ WarehouseId = order.WarehouseId,
+ WarehouseName = warehouseName
+ };
+ }
+
+ private async Task UpsertReceivableFeesAsync(
+ Order order,
+ OrderBillingContext billing,
+ string chargingStage,
+ bool replaceExistingReceivable = false)
+ {
+ await UpsertReceivableFeesAsync(
+ order,
+ billing.SnapshotBinding,
+ billing.ChargeableWeight,
+ billing.ZoneKeys,
+ billing.CargoAttribute,
+ billing.CustomerGroup,
+ chargingStage,
+ replaceExistingReceivable,
+ billing.WarehouseId,
+ billing.WarehouseName);
+ }
+
private async Task UpsertReceivableFeesAsync(
Order order,
ProductSnapshotBinding snapshotBinding,
@@ -445,8 +578,15 @@ public partial class OrderService
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage,
- bool replaceExistingReceivable = false)
+ bool replaceExistingReceivable = false,
+ int? warehouseId = null,
+ string? warehouseName = null)
{
+ if (chargeableWeight <= 0)
+ {
+ throw new BusinessException("计费重无效,无法计算应收费用");
+ }
+
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
@@ -455,11 +595,21 @@ public partial class OrderService
zoneKeys,
cargoAttribute,
customerGroup,
- chargingStage);
+ chargingStage,
+ warehouseId ?? order.WarehouseId,
+ warehouseName);
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)
@@ -475,6 +625,7 @@ public partial class OrderService
///
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
+ /// 仅用于入库、重算应付等内部环节;创建订单时不调用。
///
private async Task ConfirmServiceChannelAndPayableAsync(
Order order,
@@ -483,8 +634,14 @@ public partial class OrderService
IReadOnlyList zoneKeys,
decimal chargeableWeight,
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 filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
if (filtered.Count == 0)
@@ -514,8 +671,9 @@ public partial class OrderService
chargeableWeight,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
- customerGroup,
- chargingStage);
+ chargingStage,
+ order.WarehouseId,
+ warehouseName);
if (previewFees.Count == 0)
{
continue;
@@ -525,7 +683,8 @@ public partial class OrderService
{
Provider = provider,
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("排货规则未匹配到有效渠道买价");
}
- var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates);
- var selectedCandidate = candidates.First(x => x.Channel.Id == selectedChannel.Id);
- var channelPriceBindingFinal = await ResolveChannelPriceBindingAsync(selectedChannel.Id, bindTime);
- var payableFees = ParcelBillingCalculator.CalculatePayableFees(
- channelPriceBindingFinal.Prices,
- channelPriceBindingFinal.PriceVersion,
- channelPriceBindingFinal.PriceEffectiveTime,
- chargeableWeight,
- zoneKeys,
- order.CargoAttribute ?? CargoAttributeCodes.General,
- customerGroup,
- chargingStage);
-
- if (payableFees.Count == 0
- && ParcelBillingCalculator.HasEnabledPayablePriceItems(channelPriceBindingFinal.Prices))
+ var selectedCandidate = AllocationResolver.SelectCandidate(scheme, candidates);
+ var payableFees = selectedCandidate.PayableFees.ToList();
+ if (payableFees.Count == 0)
{
throw new BusinessException("渠道买价未配置或未匹配到有效价格");
}
- order.ChannelCode = selectedChannel.ChannelCode;
+ order.ChannelCode = selectedCandidate.Channel.ChannelCode;
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
order,
selectedCandidate.Provider,
- selectedChannel);
- if (payableFees.Count > 0)
+ selectedCandidate.Channel);
+ await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
+ await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
+ order.AllocationPendingReason = string.Empty;
+ }
+
+ private Task ConfirmServiceChannelAndPayableAsync(
+ Order order,
+ SalesProduct product,
+ OrderBillingContext billing,
+ string chargingStage) =>
+ ConfirmServiceChannelAndPayableAsync(
+ order,
+ product,
+ billing.SnapshotBinding,
+ billing.ZoneKeys,
+ billing.ChargeableWeight,
+ chargingStage,
+ billing.CustomerGroup);
+
+ ///
+ /// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。
+ ///
+ private async Task TryConfirmServiceChannelAndPayableAsync(
+ Order order,
+ SalesProduct product,
+ OrderBillingContext billing,
+ string chargingStage)
+ {
+ try
{
- await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
- await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
+ 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("未配置计重进位");
}
- var snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
- var fromSnapshot = snapshotRanges is { Count: > 0 }
- && snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
- IReadOnlyList ranges;
- if (fromSnapshot)
- {
- ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
- }
- else
- {
- var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId.Value);
- if (rounding == null)
- {
- throw new BusinessException("未配置计重进位");
- }
+ var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
+ var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
- ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
- }
-
- try
- {
- forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
- }
- catch (InvalidOperationException) when (fromSnapshot)
- {
- var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
- if (dbRanges.Count == 0)
- {
- throw;
- }
-
- forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges);
- }
+ forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
+ rawWeight,
+ ranges,
+ rulesSnapshot,
+ weightRoundingId.Value,
+ dbRanges);
}
catch (BusinessException)
{
@@ -993,6 +1150,135 @@ public partial class OrderService
}
}
+ private async Task> ResolveWeightRoundingRangesAsync(
+ SalesProduct product,
+ SalesProductRulesPublishSnapshotDto? rulesSnapshot,
+ int weightRoundingId)
+ {
+ var snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
+ var fromSnapshot = snapshotRanges is { Count: > 0 }
+ && snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
+ if (fromSnapshot)
+ {
+ return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
+ }
+
+ var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
+ if (rounding == null)
+ {
+ throw new BusinessException("未配置计重进位");
+ }
+
+ return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
+ }
+
+ private static decimal ApplyRoundingWithSnapshotFallback(
+ decimal rawWeight,
+ IReadOnlyList ranges,
+ SalesProductRulesPublishSnapshotDto? rulesSnapshot,
+ int weightRoundingId,
+ IReadOnlyList dbFallbackRanges)
+ {
+ var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
+ && rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
+ try
+ {
+ return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
+ }
+ catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0)
+ {
+ return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges);
+ }
+ }
+
+ ///
+ /// 查价专用:无尺寸时仅按实重进位计计费重(不算体积重)。
+ ///
+ private async Task ApplyQuoteChargeableWeightFromActualOnlyAsync(
+ SalesProduct product,
+ SalesProductRulesPublishSnapshotDto? rulesSnapshot,
+ OrderWeight forecast)
+ {
+ var actualWeight = forecast.ActualWeight ?? 0;
+ if (actualWeight <= 0)
+ {
+ throw new BusinessException("货物重量无效");
+ }
+
+ forecast.VolumetricWeight = 0;
+ forecast.Cubic = null;
+
+ var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot);
+ if (!weightRoundingId.HasValue)
+ {
+ throw new BusinessException("未配置计重进位");
+ }
+
+ var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
+ var dbRanges = ranges;
+ if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
+ && rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)))
+ {
+ dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
+ }
+
+ try
+ {
+ forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
+ actualWeight,
+ ranges,
+ rulesSnapshot,
+ weightRoundingId.Value,
+ dbRanges);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new BusinessException(ex.Message);
+ }
+ }
+
+ private static void EnsureBillingProductActive(SalesProduct product)
+ {
+ if (product.IsActive != EnableStatus.Enabled)
+ {
+ throw new BusinessException("订单关联的销售产品未启用,无法计费");
+ }
+ }
+
+ private static void ApplyMeasuredDimensions(
+ OrderWeight weightRow,
+ decimal length,
+ decimal width,
+ decimal height,
+ decimal actualWeight)
+ {
+ weightRow.Length = length;
+ weightRow.Width = width;
+ weightRow.Height = height;
+ weightRow.ActualWeight = actualWeight;
+ weightRow.Cubic = ParcelBillingCalculator.CalculateCubic(length, width, height);
+ }
+
+ private async Task ApplyStageReceivableBillingAsync(
+ Order order,
+ SalesProduct product,
+ OrderWeight weightRow,
+ ProductSnapshotBinding snapshotBinding,
+ string chargingStage)
+ {
+ var billing = await ResolveOrderBillingContextAsync(
+ order,
+ product,
+ weightRow.ChargeableWeight ?? 0,
+ snapshotBinding);
+
+ await UpsertReceivableFeesAsync(
+ order,
+ billing,
+ chargingStage,
+ replaceExistingReceivable: true);
+ }
+
private static int? ResolveEffectiveWeightRuleId(
SalesProduct product,
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
@@ -1129,6 +1415,7 @@ public partial class OrderService
IsIntercept = 0,
IsRemote = 0,
PredeclareFailReason = string.Empty,
+ AllocationPendingReason = string.Empty,
SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion,
LabelTemplateId = product.LabelTemplateId,
@@ -1243,7 +1530,7 @@ public partial class OrderService
{
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))
{
return generated;
@@ -1276,11 +1563,7 @@ public partial class OrderService
throw new BusinessException("运单号不能为空");
}
- if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
- || length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
- {
- throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
- }
+ ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "收货");
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
@@ -1293,10 +1576,7 @@ public partial class OrderService
throw new BusinessException("订单未绑定销售产品,无法入库计费");
}
- if (order.OrderStatus is not (OrderStatusCodes.Predeclared or OrderStatusCodes.Received))
- {
- throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许入库收货");
- }
+ ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
@@ -1304,6 +1584,8 @@ public partial class OrderService
throw new BusinessException("订单关联的销售产品不存在");
}
+ EnsureBillingProductActive(product);
+
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
@@ -1313,55 +1595,33 @@ public partial class OrderService
throw new BusinessException("订单缺少收货件重尺记录");
}
- receive.Length = length;
- receive.Width = width;
- receive.Height = height;
- receive.ActualWeight = actualWeight;
- receive.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
+ ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
- var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
- var recipient = recipients.FirstOrDefault();
- var zoneKeys = await ResolvePriceZoneKeysAsync(
+ var billing = await ResolveOrderBillingContextAsync(
+ order,
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;
+ receive.ChargeableWeight ?? 0,
+ snapshotBinding);
await UpsertReceivableFeesAsync(
order,
- snapshotBinding,
- receive.ChargeableWeight ?? 0,
- zoneKeys,
- order.CargoAttribute ?? CargoAttributeCodes.General,
- customer?.CustomerGroup,
+ billing,
OrderFeeChargingStageCodes.Inbound,
replaceExistingReceivable: true);
- await ConfirmServiceChannelAndPayableAsync(
+ var allocated = await TryConfirmServiceChannelAndPayableAsync(
order,
product,
- snapshotBinding,
- zoneKeys,
- receive.ChargeableWeight ?? 0,
- OrderFeeChargingStageCodes.Inbound,
- customer?.CustomerGroup);
+ billing,
+ OrderFeeChargingStageCodes.Inbound);
- order.OrderStatus = OrderStatusCodes.PendingPayment;
+ order.OrderStatus = allocated
+ ? OrderStatusCodes.PendingPayment
+ : OrderStatusCodes.PendingAllocation;
order.ReceiveTime = DateTime.Now;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
@@ -1382,11 +1642,7 @@ public partial class OrderService
throw new BusinessException("运单号不能为空");
}
- if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
- || length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
- {
- throw new BusinessException("发货实重与长宽高不能为空且须大于 0");
- }
+ ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "发货");
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
@@ -1421,6 +1677,8 @@ public partial class OrderService
throw new BusinessException("订单关联的销售产品不存在");
}
+ EnsureBillingProductActive(product);
+
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
@@ -1430,44 +1688,18 @@ public partial class OrderService
throw new BusinessException("订单缺少发货件重尺记录");
}
- ship.Length = length;
- ship.Width = width;
- ship.Height = height;
- ship.ActualWeight = actualWeight;
- ship.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
+ 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 recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
- var recipient = recipients.FirstOrDefault();
- var zoneKeys = await ResolvePriceZoneKeysAsync(
- product,
- snapshotBinding.Rules,
- snapshotBinding.Price,
- order.ConsigneeCountry ?? string.Empty,
- recipient?.Province,
- recipient?.City,
- recipient?.ZipCode);
- if (zoneKeys == null || zoneKeys.Count == 0)
- {
- throw new BusinessException("价格分区未匹配(规则快照)");
- }
-
- var customer = order.CustomerId.HasValue
- ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
- : null;
-
- await UpsertReceivableFeesAsync(
+ await ApplyStageReceivableBillingAsync(
order,
+ product,
+ ship,
snapshotBinding,
- ship.ChargeableWeight ?? 0,
- zoneKeys,
- order.CargoAttribute ?? CargoAttributeCodes.General,
- customer?.CustomerGroup,
- OrderFeeChargingStageCodes.Outbound,
- replaceExistingReceivable: true);
+ OrderFeeChargingStageCodes.Outbound);
var now = DateTime.Now;
order.OrderStatus = OrderStatusCodes.PendingOnline;
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
index 070733f..61f6b8d 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs
@@ -2,6 +2,8 @@ using Fake.Application;
using Oms.Application.Dtos.Orders;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
+using Oms.Domain.OrdersAggregate.Consts;
+using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Entities;
@@ -97,34 +99,62 @@ public partial class OrderService
};
}
+ private sealed record QuoteZoneAttempt(
+ IReadOnlyList ZoneKeys,
+ bool IsZoneFallback,
+ string? ZoneName);
+
private async Task BuildPriceCheckResultAsync(OrderPriceCheckInput input)
{
ValidatePriceCheckInput(input);
+ var profile = PriceCheckInputProfile.FromInput(input);
var bindTime = DateTime.Now;
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
var cargoTargets = ResolvePriceCheckCargoTargets(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
{
CountryCode = countryCode,
Province = input.DestinationProvince,
PostalCode = input.PostalCode,
- Length = input.Length!.Value,
- Width = input.Width!.Value,
- Height = input.Height!.Value,
+ Length = dimensionLength,
+ Width = dimensionWidth,
+ Height = dimensionHeight,
ActualWeight = input.CargoWeight!.Value
};
var customerGroup = await ResolveCurrentCustomerGroupAsync();
var shippingKeyword = input.ShippingMethod?.Trim();
- var products = await SalesProductRepository.GetListAsync(x =>
- x.IsActive == EnableStatus.Enabled &&
- (string.IsNullOrWhiteSpace(shippingKeyword)
- || x.ProductName.Contains(shippingKeyword)
- || x.ProductCode.Contains(shippingKeyword)));
+ List 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.ProductName.Contains(shippingKeyword) ||
+ x.ProductCode.Contains(shippingKeyword)));
+ }
+ }
- var result = new OrderPriceCheckResultOutput();
+ var result = new OrderPriceCheckResultOutput
+ {
+ QueryHint = PriceCheckHintBuilder.BuildQueryHint(profile)
+ };
if (products.Count == 0)
{
result.Failures.Add(new OrderPriceCheckFailureOutput
@@ -161,6 +191,7 @@ public partial class OrderService
var attempt = await TryComputePriceCheckQuoteAsync(
product,
quoteContext,
+ profile,
customerGroup,
bindTime,
cargo.DisplayName,
@@ -170,6 +201,7 @@ public partial class OrderService
{
var quote = attempt.Quote!;
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
+ var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
result.Items.Add(new OrderPriceCheckOutput
{
Id = quote.ProductId,
@@ -191,7 +223,11 @@ public partial class OrderService
TotalCostNum = summary.TotalCost,
RulesVersion = quote.RulesVersion,
PriceVersion = quote.PriceVersion,
- IsRemote = quote.IsRemote
+ IsRemote = quote.IsRemote,
+ QuoteLevel = itemHint.QuoteLevel,
+ MissingFieldCodes = itemHint.MissingFieldCodes,
+ Disclaimers = itemHint.Disclaimers,
+ ZoneName = quote.ZoneName
});
continue;
}
@@ -222,7 +258,12 @@ public partial class OrderService
if (codes.Count == 0)
{
- codes.Add(string.Empty);
+ return
+ [
+ BuildCargoTarget(CargoAttributeCodes.General),
+ BuildCargoTarget(CargoAttributeCodes.Electronics),
+ BuildCargoTarget(CargoAttributeCodes.Sensitive)
+ ];
}
return codes.Select(code =>
@@ -233,6 +274,12 @@ public partial class OrderService
}).ToList();
}
+ private static PriceCheckCargoTarget BuildCargoTarget(string cargoAttribute) =>
+ new(
+ cargoAttribute,
+ ParcelBillingCalculator.GetCargoAttributeLabel(cargoAttribute),
+ cargoAttribute);
+
private async Task> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input)
{
var ids = (input.WarehouseIds ?? [])
@@ -262,11 +309,7 @@ public partial class OrderService
throw new BusinessException("货物重量必须大于0");
}
- if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
- || input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
- {
- throw new BusinessException("长宽高必须大于0");
- }
+ ParcelBillingValidators.EnsurePriceCheckDimensions(input.Length, input.Width, input.Height);
}
private async Task ResolveDestinationCountryCodeAsync(string destinationCountry)
@@ -337,6 +380,7 @@ public partial class OrderService
private async Task TryComputePriceCheckQuoteAsync(
SalesProduct product,
ParcelQuoteContext context,
+ PriceCheckInputProfile profile,
CustomerGroup? customerGroup,
DateTime bindTime,
string cargoTypeName,
@@ -357,7 +401,8 @@ public partial class OrderService
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
}
- if (await IsBlacklistHitAsync(
+ if (profile.HasPostalCode
+ && await IsBlacklistHitAsync(
product,
snapshotBinding.Rules,
context.CountryCode,
@@ -373,99 +418,80 @@ public partial class OrderService
warehouseName);
}
- var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
+ OrderWeight forecast;
try
{
- await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
+ if (profile.HasDimensions)
+ {
+ forecast = ParcelBillingCalculator.BuildForecastWeight(context);
+ 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)
{
return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName);
}
- var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
- if (!priceAreaId.HasValue)
+ var zoneAttempts = await BuildQuoteZoneAttemptsAsync(product, snapshotBinding, context, profile);
+ if (zoneAttempts.Count == 0)
{
- var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
- var snapshotZoneHint = snapshotZones.Count > 0
- ? $"卖价明细分区=[{string.Join(", ", snapshotZones)}],"
- : string.Empty;
return PriceCheckFailure(
product,
"zone",
- $"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键",
- cargoTypeName,
- warehouseName);
- }
-
- 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})," +
- "请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致",
+ profile.HasPostalCode
+ ? $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区"
+ : $"目的国 {context.CountryCode} 未匹配到可参考的价格分区",
cargoTypeName,
warehouseName);
}
var chargeableWeight = forecast.ChargeableWeight ?? 0;
- var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
- snapshotBinding.Price,
- snapshotBinding.PriceVersion,
- snapshotBinding.PriceEffectiveTime,
- chargeableWeight,
- zoneKeys,
- context.CargoAttribute,
- customerGroup,
- context.WarehouseId,
- context.WarehouseName);
+ ParcelQuoteResult? bestQuote = null;
+ decimal bestTotal = decimal.MaxValue;
- if (feeLines.Count == 0)
+ foreach (var zoneAttempt in zoneAttempts)
{
- if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
+ var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
+ snapshotBinding.Price,
+ snapshotBinding.PriceVersion,
+ snapshotBinding.PriceEffectiveTime,
+ chargeableWeight,
+ zoneAttempt.ZoneKeys,
+ context.CargoAttribute,
+ customerGroup,
+ context.WarehouseId,
+ context.WarehouseName);
+ if (feeLines.Count == 0)
{
- return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
+ continue;
}
- return PriceCheckFailure(
- product,
- "price",
- ParcelBillingCalculator.ExplainQuotePriceMiss(
- snapshotBinding.Price,
- chargeableWeight,
- zoneKeys,
- context.CargoAttribute,
- customerGroup,
- context.WarehouseId,
- context.WarehouseName),
- cargoTypeName,
- warehouseName);
- }
+ var total = feeLines.Sum(x => x.Fee.Amount);
+ if (total >= bestTotal)
+ {
+ continue;
+ }
- var isRemote = await IsRemoteHitAsync(
- product,
- snapshotBinding.Rules,
- context.CountryCode,
- context.Province,
- context.City,
- context.PostalCode);
+ var isRemote = profile.HasPostalCode
+ && await IsRemoteHitAsync(
+ product,
+ snapshotBinding.Rules,
+ context.CountryCode,
+ context.Province,
+ context.City,
+ context.PostalCode);
- return new PriceCheckAttemptResult
- {
- Quote = new ParcelQuoteResult
+ bestTotal = total;
+ bestQuote = new ParcelQuoteResult
{
ProductId = product.Id,
ProductCode = product.ProductCode,
@@ -474,11 +500,92 @@ public partial class OrderService
PriceVersion = snapshotBinding.PriceVersion,
VolumetricWeight = forecast.VolumetricWeight ?? 0,
ChargeableWeight = chargeableWeight,
- ZoneName = zoneKeys[0],
+ ZoneName = zoneAttempt.ZoneName ?? zoneAttempt.ZoneKeys.FirstOrDefault(),
IsRemote = isRemote,
+ UsedZoneFallback = zoneAttempt.IsZoneFallback,
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> BuildQuoteZoneAttemptsAsync(
+ SalesProduct product,
+ ProductSnapshotBinding snapshotBinding,
+ ParcelQuoteContext context,
+ PriceCheckInputProfile profile)
+ {
+ var attempts = new List();
+
+ 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(
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
index 1e1677a..67a4c55 100644
--- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
+++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs
@@ -347,6 +347,16 @@ internal static class ParcelBillingCalculator
RightPostcode = rule.RightPostcode
};
+ ///
+ /// 卖价/买价明细匹配共用上下文(分区、货物属性、计费重、仓库)。
+ ///
+ internal readonly record struct PriceDetailMatchContext(
+ IReadOnlyList ZoneKeys,
+ string CargoAttribute,
+ decimal ChargeableWeight,
+ int? WarehouseId = null,
+ string? WarehouseName = null);
+
public static List CalculateQuoteReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
@@ -622,7 +632,7 @@ internal static class ParcelBillingCalculator
}
///
- /// 应付费用:买价全部启用项参与;ChargingStage 由件重尺阶段决定。
+ /// 应付费用:买价全部启用项参与;渠道买价不区分客户组。
///
public static List CalculatePayableFees(
IReadOnlyList prices,
@@ -631,8 +641,9 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight,
IReadOnlyList zoneKeys,
string cargoAttribute,
- CustomerGroup? customerGroup,
- string chargingStage)
+ string chargingStage,
+ int? warehouseId = null,
+ string? warehouseName = null)
{
var items = prices.Select(p => new PayableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details));
@@ -640,15 +651,12 @@ internal static class ParcelBillingCalculator
items,
priceVersion,
priceEffectiveTime,
- chargeableWeight,
- zoneKeys,
- cargoAttribute,
- customerGroup,
+ new PriceDetailMatchContext(zoneKeys, cargoAttribute, chargeableWeight, warehouseId, warehouseName),
chargingStage);
}
///
- /// 预报阶段应付费用(买价快照,计费重取 downstream 行,调用方传入对应重量)。
+ /// 预报阶段应付费用(买价快照)。
///
public static List CalculateForecastPayableFees(
IReadOnlyList prices,
@@ -657,7 +665,8 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
- CustomerGroup? customerGroup) =>
+ int? warehouseId = null,
+ string? warehouseName = null) =>
CalculatePayableFees(
prices,
priceVersion,
@@ -665,8 +674,9 @@ internal static class ParcelBillingCalculator
chargeableWeight,
new[] { zoneName },
cargoAttribute,
- customerGroup,
- OrderFeeChargingStageCodes.Forecast);
+ OrderFeeChargingStageCodes.Forecast,
+ warehouseId,
+ warehouseName);
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot);
@@ -803,8 +813,14 @@ internal static class ParcelBillingCalculator
CustomerGroup? customerGroup,
string chargingStage,
int? warehouseId = null,
- string? warehouseName = null) =>
- CalculateReceivableFeeLines(
+ string? warehouseName = null)
+ {
+ if (chargeableWeight <= 0)
+ {
+ return [];
+ }
+
+ return CalculateReceivableFeeLines(
priceItems,
priceVersion,
priceEffectiveTime,
@@ -815,6 +831,7 @@ internal static class ParcelBillingCalculator
chargingStage,
warehouseId,
warehouseName).Select(x => x.Fee).ToList();
+ }
private static List CalculateReceivableFeeLines(
IEnumerable priceItems,
@@ -885,38 +902,35 @@ internal static class ParcelBillingCalculator
IEnumerable priceItems,
string priceVersion,
DateTime priceEffectiveTime,
- decimal chargeableWeight,
- IReadOnlyList zoneKeys,
- string cargoAttribute,
- CustomerGroup? customerGroup,
- string chargingStage,
- int? warehouseId = null,
- string? warehouseName = null)
+ PriceDetailMatchContext match,
+ string chargingStage)
{
+ if (match.ChargeableWeight <= 0)
+ {
+ return [];
+ }
+
var fees = new List();
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
{
var detail = MatchPriceDetail(
priceItem.Details,
- zoneKeys,
- cargoAttribute,
- chargeableWeight,
- warehouseId,
- warehouseName);
+ match.ZoneKeys,
+ match.CargoAttribute,
+ match.ChargeableWeight,
+ match.WarehouseId,
+ match.WarehouseName);
if (detail == null)
{
continue;
}
- var amount = CalculateDetailAmount(
- priceItem.Details,
- detail,
- zoneKeys,
- cargoAttribute,
- chargeableWeight,
- customerGroup,
- warehouseId,
- warehouseName);
+ var amount = CalculatePayableDetailAmount(priceItem.Details, detail, match);
+ if (amount <= 0)
+ {
+ continue;
+ }
+
fees.Add(new OrderFee
{
Type = OrderFeeTypeCodes.Payable,
@@ -932,6 +946,118 @@ internal static class ParcelBillingCalculator
return fees;
}
+ ///
+ /// 渠道买价明细金额:与下游服务商结算,仅使用 CalculatedValue / 续重配置,不区分客户组。
+ ///
+ private static decimal CalculatePayableDetailAmount(
+ IReadOnlyList 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 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 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(
byte Status,
string CostItem,
diff --git a/src/Oms.Application/Services/Orders/ParcelBillingValidators.cs b/src/Oms.Application/Services/Orders/ParcelBillingValidators.cs
new file mode 100644
index 0000000..b814d6f
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/ParcelBillingValidators.cs
@@ -0,0 +1,64 @@
+namespace Oms.Application.Services.Orders;
+
+using Fake.Application;
+using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Consts;
+
+///
+/// 小包计费各环节共用的件重尺校验。
+///
+internal static class ParcelBillingValidators
+{
+ ///
+ /// 预报/入库/出库:长宽高与实重均须 > 0。
+ ///
+ 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");
+ }
+ }
+
+ ///
+ /// 查价:尺寸要么全空(按实重估算),要么三者齐全。
+ ///
+ 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)}」,不允许入库收货");
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/ParcelQuoteResult.cs b/src/Oms.Application/Services/Orders/ParcelQuoteResult.cs
index 53ba0a1..dd48d54 100644
--- a/src/Oms.Application/Services/Orders/ParcelQuoteResult.cs
+++ b/src/Oms.Application/Services/Orders/ParcelQuoteResult.cs
@@ -25,5 +25,7 @@ internal sealed class ParcelQuoteResult
public bool IsRemote { get; init; }
+ public bool UsedZoneFallback { get; init; }
+
public IReadOnlyList FeeLines { get; init; } = [];
}
diff --git a/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
new file mode 100644
index 0000000..8d489db
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
@@ -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 MissingFieldCodes, List 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 BuildDisclaimers(IReadOnlyList missingFieldCodes)
+ {
+ var disclaimers = new List();
+ 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;
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
new file mode 100644
index 0000000..fd72af7
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/PriceCheckInputProfile.cs
@@ -0,0 +1,93 @@
+namespace Oms.Application.Services.Orders;
+
+using Oms.Application.Dtos.Orders;
+
+///
+/// 查价入参完整度(用于估算逻辑与对客提示)。
+///
+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 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();
+ 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";
+}
diff --git a/src/Oms.Application/Shared/AllocationResolver.cs b/src/Oms.Application/Shared/AllocationResolver.cs
index b31ca31..fe9929c 100644
--- a/src/Oms.Application/Shared/AllocationResolver.cs
+++ b/src/Oms.Application/Shared/AllocationResolver.cs
@@ -1,6 +1,7 @@
using Oms.Application.Dtos.Channels;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Shared;
@@ -16,6 +17,9 @@ public static class AllocationResolver
public ServiceChannel Channel { get; init; } = null!;
public decimal TotalPayableCost { get; init; }
+
+ /// 候选渠道已算出的应付费用(选渠后可直接落库,避免重复算价)。
+ public IReadOnlyList PayableFees { get; init; } = [];
}
public static List FilterProviders(
@@ -28,7 +32,10 @@ public static class AllocationResolver
.OrderBy(x => x.SerialNumber)
.ToList();
- public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList candidates)
+ public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList candidates) =>
+ SelectCandidate(scheme, candidates).Channel;
+
+ public static ChannelCandidate SelectCandidate(string? scheme, IReadOnlyList candidates)
{
if (candidates.Count == 0)
{
@@ -44,18 +51,15 @@ public static class AllocationResolver
AllocationSchemeCodes.TimeFastest => candidates
.OrderBy(x => x.Channel.MinTime ?? int.MaxValue)
.ThenBy(x => x.Provider.SerialNumber)
- .First()
- .Channel,
+ .First(),
AllocationSchemeCodes.OverallBest => candidates
.OrderByDescending(x => x.Provider.Weight)
.ThenBy(x => x.Provider.SerialNumber)
- .First()
- .Channel,
+ .First(),
_ => candidates
.OrderBy(x => x.TotalPayableCost)
.ThenBy(x => x.Provider.SerialNumber)
.First()
- .Channel
};
}
}
diff --git a/src/Oms.Application/Shared/OmsDocHelper.cs b/src/Oms.Application/Shared/OmsDocHelper.cs
index 83441cd..513cdd5 100644
--- a/src/Oms.Application/Shared/OmsDocHelper.cs
+++ b/src/Oms.Application/Shared/OmsDocHelper.cs
@@ -160,6 +160,7 @@ public static class OmsDocHelper
OrderStatusCodes.Draft => "草稿",
OrderStatusCodes.Predeclared => "已预报",
OrderStatusCodes.Received => "已收货",
+ OrderStatusCodes.PendingAllocation => "待排货",
OrderStatusCodes.PendingPayment => "待支付",
OrderStatusCodes.PendingShip => "待发货",
OrderStatusCodes.PendingOnline => "待上网",
diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderStatusCodes.cs
index 6615452..6debda2 100644
--- a/src/Oms.Domain/OrdersAggregate/Consts/OrderStatusCodes.cs
+++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderStatusCodes.cs
@@ -5,6 +5,7 @@ public static class OrderStatusCodes
public const string Draft = "draft";
public const string Predeclared = "predeclared";
public const string Received = "received";
+ public const string PendingAllocation = "pendingAllocation";
public const string PendingPayment = "pendingPayment";
public const string PendingShip = "pendingShip";
public const string PendingOnline = "pendingOnline";
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index 196abb8..ecf36d8 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -48,6 +48,12 @@ public class Order : OmsAuditedAggregateRoot
public string? PredeclareFailReason { get; set; }
+ ///
+ /// 待排货原因:入库等环节排货或渠道买价未成功时写入,重算应付成功后清空。
+ ///
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? AllocationPendingReason { get; set; }
+
public string? SalesProductRulesVersion { get; set; }
public string? SalesProductPriceVersion { get; set; }
diff --git a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
index 95448cb..537eb2a 100644
--- a/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
+++ b/src/Oms.Tests/OrderBatchJobCoordinatorGovernanceTests.cs
@@ -1,6 +1,7 @@
using FluentAssertions;
using Fake.Application;
using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
using Moq;
using Oms.Application.Dtos.Orders;
using Oms.Application.Messaging;
@@ -23,11 +24,21 @@ public class OrderBatchJobCoordinatorGovernanceTests
public OrderBatchJobCoordinatorGovernanceTests()
{
_publisher.SetupGet(x => x.IsEnabled).Returns(true);
+ var batchOptions = Options.Create(new OrderBatchRabbitMqOptions());
+ var actionExecutor = new Mock(MockBehavior.Loose);
+ var processor = new OrderBatchJobProcessor(
+ _batchJobRepository.Object,
+ _orderRepository.Object,
+ actionExecutor.Object,
+ batchOptions,
+ NullLogger.Instance);
_coordinator = new OrderBatchJobCoordinator(
_batchJobRepository.Object,
_orderRepository.Object,
_countryRepository.Object,
_publisher.Object,
+ processor,
+ batchOptions,
NullLogger.Instance);
}
diff --git a/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs b/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs
index 792adcf..d6f23ec 100644
--- a/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs
+++ b/src/Oms.Tests/ParcelBillingCalculatorDemoTests.cs
@@ -129,8 +129,7 @@ public class ParcelBillingCalculatorDemoTests
effectiveTime,
chargeableWeight,
"美西",
- quoteContext.CargoAttribute,
- null);
+ quoteContext.CargoAttribute);
var payableSummary = ParcelBillingCalculator.SummarizeFees(payableFees);
diff --git a/src/Oms.Tests/ParcelBillingCalculatorTests.cs b/src/Oms.Tests/ParcelBillingCalculatorTests.cs
index d40a06f..4b20d45 100644
--- a/src/Oms.Tests/ParcelBillingCalculatorTests.cs
+++ b/src/Oms.Tests/ParcelBillingCalculatorTests.cs
@@ -302,8 +302,7 @@ public class ParcelBillingCalculatorTests
_meta.EffectiveTime,
(decimal)chargeableWeight,
Zone,
- "general",
- null);
+ "general");
fees.Should().ContainSingle();
fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
@@ -326,8 +325,7 @@ public class ParcelBillingCalculatorTests
_meta.EffectiveTime,
1.2M,
Zone,
- "general",
- null);
+ "general");
fees.Should().BeEmpty();
ParcelBillingCalculator.HasEnabledPayablePriceItems(prices).Should().BeFalse();
@@ -345,7 +343,7 @@ public class ParcelBillingCalculatorTests
var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
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);
payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
diff --git a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
index 7c846b9..c260463 100644
--- a/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
+++ b/src/Oms.Tests/PriceCheckSnapshotVerificationTests.cs
@@ -21,7 +21,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 1,
- Status = 1,
+ Status = 2,
CostItem = "freight",
Details =
[
@@ -34,7 +34,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 2,
- Status = 1,
+ Status = 2,
CostItem = "handling",
Details = [Detail(28, 2, "total", "3", "0,10", 20)]
})
@@ -98,7 +98,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 1,
- Status = 1,
+ Status = 2,
CostItem = "freight",
Details =
[
@@ -109,7 +109,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 2,
- Status = 1,
+ Status = 2,
CostItem = "handling",
Details = [Detail(44, 2, "total", "3", "(0-10]", 23)]
})
@@ -188,7 +188,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 1,
- Status = 1,
+ Status = 2,
CostItem = "freight",
Details =
[
@@ -199,7 +199,7 @@ public class PriceCheckSnapshotVerificationTests
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
{
Id = 2,
- Status = 1,
+ Status = 2,
CostItem = "handling",
Details = [Detail(38, 2, "total", "3", "(0-10]", 20)]
})
diff --git a/src/Oms.Tests/Support/ParcelBillingTestData.cs b/src/Oms.Tests/Support/ParcelBillingTestData.cs
index b7755dc..78ea605 100644
--- a/src/Oms.Tests/Support/ParcelBillingTestData.cs
+++ b/src/Oms.Tests/Support/ParcelBillingTestData.cs
@@ -29,7 +29,7 @@ internal static class ParcelBillingTestData
params ChannelPriceDetailOutput[] details) =>
new()
{
- Status = status,
+ Status = status == 0 ? (byte)0 : (byte)2,
CostItem = costItem,
Currency = "CNY",
Details = details.ToList()
@@ -41,7 +41,7 @@ internal static class ParcelBillingTestData
params ChannelPriceDetailOutput[] details) =>
new()
{
- Status = status,
+ Status = status == 0 ? (byte)0 : (byte)2,
CostItem = costItem,
Currency = "CNY",
Details = details.ToList()