feat: 小包全链路加固、待排货与运单号 IDE 前缀
查价/下单/入库/出库统一校验与计费上下文;待排货状态及可选入库排货;运单号改为 IDE+yyMMddHHmmss;补充迁移 SQL 与测试修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c2d854b83f
commit
3d5eaa223a
1
db.sql
1
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 '订单状态',
|
||||
|
||||
12
sql/migrate_order_status_pending_allocation.sql
Normal file
12
sql/migrate_order_status_pending_allocation.sql
Normal 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';
|
||||
3
sql/migrate_t_order_allocation_pending_reason.sql
Normal file
3
sql/migrate_t_order_allocation_pending_reason.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- 待排货原因:入库排货/买价未成功时记录,便于运营补配置后重算应付
|
||||
ALTER TABLE `t_order`
|
||||
ADD COLUMN `AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)' AFTER `PredeclareFailReason`;
|
||||
@ -135,6 +135,11 @@ public class OrderOutput
|
||||
/// </summary>
|
||||
public string? PredeclareFailReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待排货原因(排货或渠道买价未匹配,空表示已排货或无待办)。
|
||||
/// </summary>
|
||||
public string? AllocationPendingReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 申报币种。
|
||||
/// </summary>
|
||||
|
||||
32
src/Oms.Application/Dtos/Orders/OrderPriceCheckHintOutput.cs
Normal file
32
src/Oms.Application/Dtos/Orders/OrderPriceCheckHintOutput.cs
Normal 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";
|
||||
}
|
||||
@ -104,4 +104,24 @@ public class OrderPriceCheckOutput
|
||||
/// 是否偏远。
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@ -14,4 +14,9 @@ public class OrderPriceCheckResultOutput
|
||||
/// 试算失败或未命中的产品及原因。
|
||||
/// </summary>
|
||||
public List<OrderPriceCheckFailureOutput> Failures { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 本次查价入参完整度与全局免责声明。
|
||||
/// </summary>
|
||||
public OrderPriceCheckHintOutput? QueryHint { get; set; }
|
||||
}
|
||||
|
||||
@ -383,7 +383,9 @@ public partial class ServiceChannelService
|
||||
|
||||
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,
|
||||
SerialNumber = input.SerialNumber,
|
||||
@ -394,16 +396,78 @@ public partial class ServiceChannelService
|
||||
|
||||
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,
|
||||
FactorValuesJson = OmsDocHelper.SerializeJson(input.FactorValuesJson),
|
||||
CalculationFormula = input.CalculationFormula,
|
||||
CalculatedValue = input.CalculatedValue,
|
||||
CustomerGroupValues = OmsDocHelper.SerializeJson(input.CustomerGroupValues)
|
||||
CalculatedValue = calculatedValue,
|
||||
CustomerGroupValues = null
|
||||
};
|
||||
}).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)
|
||||
{
|
||||
return new ServiceChannelPriceSaveInput
|
||||
|
||||
@ -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;
|
||||
|
||||
33
src/Oms.Application/Services/Orders/OrderBillingContext.cs
Normal file
33
src/Oms.Application/Services/Orders/OrderBillingContext.cs
Normal 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; }
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -128,41 +128,29 @@ public partial class OrderService
|
||||
SyncWeightRows(weights, forecast);
|
||||
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,
|
||||
snapshotBinding.Rules,
|
||||
snapshotBinding.Price,
|
||||
snapshotBinding,
|
||||
new OrderBillingAddress(
|
||||
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,
|
||||
input.Receiver?.ZipCode),
|
||||
forecast.ChargeableWeight ?? 0,
|
||||
zoneKeys,
|
||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
||||
customer?.CustomerGroup,
|
||||
OrderFeeChargingStageCodes.Forecast);
|
||||
|
||||
var downstream = weights.First(x => x.Type == OrderWeightTypeCodes.Downstream);
|
||||
await ConfirmServiceChannelAndPayableAsync(
|
||||
order,
|
||||
product,
|
||||
snapshotBinding,
|
||||
zoneKeys,
|
||||
downstream.ChargeableWeight ?? forecast.ChargeableWeight ?? 0,
|
||||
OrderFeeChargingStageCodes.Forecast,
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// 自动生成运单号:IDE + yyMMddHHmmss + 4 位随机数(如 IDE2607221859123456)。
|
||||
/// </summary>
|
||||
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<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(
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
|
||||
/// 仅用于入库、重算应付等内部环节;创建订单时不调用。
|
||||
/// </summary>
|
||||
private async Task ConfirmServiceChannelAndPayableAsync(
|
||||
Order order,
|
||||
@ -483,8 +634,14 @@ public partial class OrderService
|
||||
IReadOnlyList<string> 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);
|
||||
|
||||
/// <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("未配置计重进位");
|
||||
}
|
||||
|
||||
var snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
|
||||
var fromSnapshot = snapshotRanges is { Count: > 0 }
|
||||
&& snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
||||
IReadOnlyList<WeightRoundingInfo> ranges;
|
||||
if (fromSnapshot)
|
||||
{
|
||||
ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
|
||||
}
|
||||
else
|
||||
{
|
||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId.Value);
|
||||
if (rounding == null)
|
||||
{
|
||||
throw new BusinessException("未配置计重进位");
|
||||
}
|
||||
|
||||
ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||||
}
|
||||
catch (InvalidOperationException) when (fromSnapshot)
|
||||
{
|
||||
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, 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)
|
||||
{
|
||||
@ -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(
|
||||
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;
|
||||
|
||||
@ -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<string> ZoneKeys,
|
||||
bool IsZoneFallback,
|
||||
string? ZoneName);
|
||||
|
||||
private async Task<OrderPriceCheckResultOutput> 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 =>
|
||||
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 &&
|
||||
(string.IsNullOrWhiteSpace(shippingKeyword)
|
||||
|| x.ProductName.Contains(shippingKeyword)
|
||||
|| x.ProductCode.Contains(shippingKeyword)));
|
||||
(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<List<WarehouseAddress>> 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<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
|
||||
@ -337,6 +380,7 @@ public partial class OrderService
|
||||
private async Task<PriceCheckAttemptResult> 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,89 +418,71 @@ public partial class OrderService
|
||||
warehouseName);
|
||||
}
|
||||
|
||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||
OrderWeight forecast;
|
||||
try
|
||||
{
|
||||
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;
|
||||
ParcelQuoteResult? bestQuote = null;
|
||||
decimal bestTotal = decimal.MaxValue;
|
||||
|
||||
foreach (var zoneAttempt in zoneAttempts)
|
||||
{
|
||||
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
|
||||
snapshotBinding.Price,
|
||||
snapshotBinding.PriceVersion,
|
||||
snapshotBinding.PriceEffectiveTime,
|
||||
chargeableWeight,
|
||||
zoneKeys,
|
||||
zoneAttempt.ZoneKeys,
|
||||
context.CargoAttribute,
|
||||
customerGroup,
|
||||
context.WarehouseId,
|
||||
context.WarehouseName);
|
||||
|
||||
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(
|
||||
product,
|
||||
"price",
|
||||
ParcelBillingCalculator.ExplainQuotePriceMiss(
|
||||
snapshotBinding.Price,
|
||||
chargeableWeight,
|
||||
zoneKeys,
|
||||
context.CargoAttribute,
|
||||
customerGroup,
|
||||
context.WarehouseId,
|
||||
context.WarehouseName),
|
||||
cargoTypeName,
|
||||
warehouseName);
|
||||
}
|
||||
|
||||
var isRemote = await IsRemoteHitAsync(
|
||||
var isRemote = profile.HasPostalCode
|
||||
&& await IsRemoteHitAsync(
|
||||
product,
|
||||
snapshotBinding.Rules,
|
||||
context.CountryCode,
|
||||
@ -463,9 +490,8 @@ public partial class OrderService
|
||||
context.City,
|
||||
context.PostalCode);
|
||||
|
||||
return new PriceCheckAttemptResult
|
||||
{
|
||||
Quote = new ParcelQuoteResult
|
||||
bestTotal = total;
|
||||
bestQuote = new ParcelQuoteResult
|
||||
{
|
||||
ProductId = product.Id,
|
||||
ProductCode = product.ProductCode,
|
||||
@ -474,13 +500,94 @@ 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<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(
|
||||
SalesProduct product,
|
||||
string step,
|
||||
|
||||
@ -347,6 +347,16 @@ internal static class ParcelBillingCalculator
|
||||
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(
|
||||
SalesProductPricePublishSnapshotDto priceSnapshot,
|
||||
string priceVersion,
|
||||
@ -622,7 +632,7 @@ internal static class ParcelBillingCalculator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应付费用:买价全部启用项参与;ChargingStage 由件重尺阶段决定。
|
||||
/// 应付费用:买价全部启用项参与;渠道买价不区分客户组。
|
||||
/// </summary>
|
||||
public static List<OrderFee> CalculatePayableFees(
|
||||
IReadOnlyList<ServiceChannelPriceListOutput> prices,
|
||||
@ -631,8 +641,9 @@ internal static class ParcelBillingCalculator
|
||||
decimal chargeableWeight,
|
||||
IReadOnlyList<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预报阶段应付费用(买价快照,计费重取 downstream 行,调用方传入对应重量)。
|
||||
/// 预报阶段应付费用(买价快照)。
|
||||
/// </summary>
|
||||
public static List<OrderFee> CalculateForecastPayableFees(
|
||||
IReadOnlyList<ServiceChannelPriceListOutput> 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<BillingFeeLine> CalculateReceivableFeeLines(
|
||||
IEnumerable<ReceivableBillingPriceItem> priceItems,
|
||||
@ -885,38 +902,35 @@ internal static class ParcelBillingCalculator
|
||||
IEnumerable<PayableBillingPriceItem> priceItems,
|
||||
string priceVersion,
|
||||
DateTime priceEffectiveTime,
|
||||
decimal chargeableWeight,
|
||||
IReadOnlyList<string> 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<OrderFee>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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(
|
||||
byte Status,
|
||||
string CostItem,
|
||||
|
||||
@ -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>
|
||||
/// 预报/入库/出库:长宽高与实重均须 > 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)}」,不允许入库收货");
|
||||
}
|
||||
}
|
||||
@ -25,5 +25,7 @@ internal sealed class ParcelQuoteResult
|
||||
|
||||
public bool IsRemote { get; init; }
|
||||
|
||||
public bool UsedZoneFallback { get; init; }
|
||||
|
||||
public IReadOnlyList<BillingFeeLine> FeeLines { get; init; } = [];
|
||||
}
|
||||
|
||||
77
src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
Normal file
77
src/Oms.Application/Services/Orders/PriceCheckHintBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
/// <summary>候选渠道已算出的应付费用(选渠后可直接落库,避免重复算价)。</summary>
|
||||
public IReadOnlyList<OrderFee> PayableFees { get; init; } = [];
|
||||
}
|
||||
|
||||
public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders(
|
||||
@ -28,7 +32,10 @@ public static class AllocationResolver
|
||||
.OrderBy(x => x.SerialNumber)
|
||||
.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)
|
||||
{
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,6 +160,7 @@ public static class OmsDocHelper
|
||||
OrderStatusCodes.Draft => "草稿",
|
||||
OrderStatusCodes.Predeclared => "已预报",
|
||||
OrderStatusCodes.Received => "已收货",
|
||||
OrderStatusCodes.PendingAllocation => "待排货",
|
||||
OrderStatusCodes.PendingPayment => "待支付",
|
||||
OrderStatusCodes.PendingShip => "待发货",
|
||||
OrderStatusCodes.PendingOnline => "待上网",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -48,6 +48,12 @@ public class Order : OmsAuditedAggregateRoot
|
||||
|
||||
public string? PredeclareFailReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待排货原因:入库等环节排货或渠道买价未成功时写入,重算应付成功后清空。
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 512, IsNullable = true)]
|
||||
public string? AllocationPendingReason { get; set; }
|
||||
|
||||
public string? SalesProductRulesVersion { get; set; }
|
||||
|
||||
public string? SalesProductPriceVersion { get; set; }
|
||||
|
||||
@ -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<IOrderBatchActionExecutor>(MockBehavior.Loose);
|
||||
var processor = new OrderBatchJobProcessor(
|
||||
_batchJobRepository.Object,
|
||||
_orderRepository.Object,
|
||||
actionExecutor.Object,
|
||||
batchOptions,
|
||||
NullLogger<OrderBatchJobProcessor>.Instance);
|
||||
_coordinator = new OrderBatchJobCoordinator(
|
||||
_batchJobRepository.Object,
|
||||
_orderRepository.Object,
|
||||
_countryRepository.Object,
|
||||
_publisher.Object,
|
||||
processor,
|
||||
batchOptions,
|
||||
NullLogger<OrderBatchJobCoordinator>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@ -129,8 +129,7 @@ public class ParcelBillingCalculatorDemoTests
|
||||
effectiveTime,
|
||||
chargeableWeight,
|
||||
"美西",
|
||||
quoteContext.CargoAttribute,
|
||||
null);
|
||||
quoteContext.CargoAttribute);
|
||||
|
||||
var payableSummary = ParcelBillingCalculator.SummarizeFees(payableFees);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)]
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user