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',
|
`CargoAttribute` VARCHAR(32) NULL COMMENT '货物属性:general/electronics/sensitive',
|
||||||
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
|
`ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式',
|
||||||
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
|
`PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因',
|
||||||
|
`AllocationPendingReason` VARCHAR(512) NULL COMMENT '待排货原因(排货或渠道买价未匹配)',
|
||||||
`SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本',
|
`SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本',
|
||||||
`SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本',
|
`SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本',
|
||||||
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',
|
`OrderStatus` VARCHAR(32) NOT NULL COMMENT '订单状态',
|
||||||
|
|||||||
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>
|
/// </summary>
|
||||||
public string? PredeclareFailReason { get; set; }
|
public string? PredeclareFailReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待排货原因(排货或渠道买价未匹配,空表示已排货或无待办)。
|
||||||
|
/// </summary>
|
||||||
|
public string? AllocationPendingReason { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 申报币种。
|
/// 申报币种。
|
||||||
/// </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>
|
/// </summary>
|
||||||
public bool IsRemote { get; set; }
|
public bool IsRemote { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报价精度:exact / estimated。
|
||||||
|
/// </summary>
|
||||||
|
public string QuoteLevel { get; set; } = PriceCheckQuoteLevelCodes.Exact;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本行报价涉及的缺失条件编码。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> MissingFieldCodes { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本行对客提示(如未填邮编、未填尺寸等)。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Disclaimers { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 命中的价格分区名称/键(展示用)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ZoneName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,4 +14,9 @@ public class OrderPriceCheckResultOutput
|
|||||||
/// 试算失败或未命中的产品及原因。
|
/// 试算失败或未命中的产品及原因。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<OrderPriceCheckFailureOutput> Failures { get; set; } = [];
|
public List<OrderPriceCheckFailureOutput> Failures { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本次查价入参完整度与全局免责声明。
|
||||||
|
/// </summary>
|
||||||
|
public OrderPriceCheckHintOutput? QueryHint { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,7 +383,9 @@ public partial class ServiceChannelService
|
|||||||
|
|
||||||
private static List<ServiceChannelPriceFactor> BuildPriceFactors(IEnumerable<ChannelPriceFactorInput> inputs, int? priceId = null)
|
private static List<ServiceChannelPriceFactor> BuildPriceFactors(IEnumerable<ChannelPriceFactorInput> inputs, int? priceId = null)
|
||||||
{
|
{
|
||||||
return inputs.Select(input => new ServiceChannelPriceFactor
|
return inputs
|
||||||
|
.Where(input => !IsCustomerPricingFactor(input.Factor))
|
||||||
|
.Select(input => new ServiceChannelPriceFactor
|
||||||
{
|
{
|
||||||
PriceId = priceId ?? 0,
|
PriceId = priceId ?? 0,
|
||||||
SerialNumber = input.SerialNumber,
|
SerialNumber = input.SerialNumber,
|
||||||
@ -394,16 +396,78 @@ public partial class ServiceChannelService
|
|||||||
|
|
||||||
private static List<ServiceChannelPriceDetail> BuildPriceDetails(IEnumerable<ChannelPriceDetailInput> inputs, int? priceId = null)
|
private static List<ServiceChannelPriceDetail> BuildPriceDetails(IEnumerable<ChannelPriceDetailInput> inputs, int? priceId = null)
|
||||||
{
|
{
|
||||||
return inputs.Select(input => new ServiceChannelPriceDetail
|
return inputs.Select(input =>
|
||||||
|
{
|
||||||
|
var calculatedValue = input.CalculatedValue;
|
||||||
|
if ((calculatedValue == null || calculatedValue == 0) && input.CustomerGroupValues != null)
|
||||||
|
{
|
||||||
|
calculatedValue = ExtractFirstCustomerGroupPrice(input.CustomerGroupValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServiceChannelPriceDetail
|
||||||
{
|
{
|
||||||
PriceId = priceId ?? 0,
|
PriceId = priceId ?? 0,
|
||||||
FactorValuesJson = OmsDocHelper.SerializeJson(input.FactorValuesJson),
|
FactorValuesJson = OmsDocHelper.SerializeJson(input.FactorValuesJson),
|
||||||
CalculationFormula = input.CalculationFormula,
|
CalculationFormula = input.CalculationFormula,
|
||||||
CalculatedValue = input.CalculatedValue,
|
CalculatedValue = calculatedValue,
|
||||||
CustomerGroupValues = OmsDocHelper.SerializeJson(input.CustomerGroupValues)
|
CustomerGroupValues = null
|
||||||
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道买价不含客户组因素;保存时丢弃客户组列,并将历史数据首价迁移到 CalculatedValue。
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCustomerPricingFactor(string? factor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(factor))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = factor.Trim();
|
||||||
|
return value.Equals("customer", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| value.Equals("客户", StringComparison.Ordinal)
|
||||||
|
|| value.Equals("客户组", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ExtractFirstCustomerGroupPrice(object? customerGroupValues)
|
||||||
|
{
|
||||||
|
if (customerGroupValues == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = OmsDocHelper.SerializeJson(customerGroupValues);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var map = OmsDocHelper.DeserializeJson<Dictionary<string, decimal>>(json);
|
||||||
|
if (map == null || map.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in map)
|
||||||
|
{
|
||||||
|
if (pair.Value > 0)
|
||||||
|
{
|
||||||
|
return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map.Values.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static ServiceChannelPriceSaveInput MapPriceSaveInput(ServiceChannelPriceListOutput output)
|
private static ServiceChannelPriceSaveInput MapPriceSaveInput(ServiceChannelPriceListOutput output)
|
||||||
{
|
{
|
||||||
return new ServiceChannelPriceSaveInput
|
return new ServiceChannelPriceSaveInput
|
||||||
|
|||||||
@ -95,11 +95,14 @@ public class InboundRecordService : ApplicationService
|
|||||||
throw new BusinessException("未找到对应订单,请检查单号");
|
throw new BusinessException("未找到对应订单,请检查单号");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.Weight.HasValue || !input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
|
ParcelBillingValidators.EnsurePositiveDimensions(
|
||||||
|| input.Weight <= 0 || input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
|
input.Length,
|
||||||
{
|
input.Width,
|
||||||
throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
|
input.Height,
|
||||||
}
|
input.Weight,
|
||||||
|
"收货");
|
||||||
|
|
||||||
|
ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
|
||||||
|
|
||||||
int? salesProductId = null;
|
int? salesProductId = null;
|
||||||
string? destinationCountry = order.ConsigneeCountry;
|
string? destinationCountry = order.ConsigneeCountry;
|
||||||
|
|||||||
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)
|
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
|
||||||
? null
|
? null
|
||||||
: order.PredeclareFailReason.Trim(),
|
: order.PredeclareFailReason.Trim(),
|
||||||
|
AllocationPendingReason = string.IsNullOrWhiteSpace(order.AllocationPendingReason)
|
||||||
|
? null
|
||||||
|
: order.AllocationPendingReason.Trim(),
|
||||||
DeclaredCurrency = order.DeclaredCurrency,
|
DeclaredCurrency = order.DeclaredCurrency,
|
||||||
DeclaredSum = order.DeclaredSum,
|
DeclaredSum = order.DeclaredSum,
|
||||||
Quantity = order.Quantity,
|
Quantity = order.Quantity,
|
||||||
|
|||||||
@ -331,32 +331,15 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||||||
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
var billing = await ResolveOrderBillingContextAsync(
|
||||||
var recipient = recipients.FirstOrDefault();
|
order,
|
||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
weightRow.ChargeableWeight ?? 0,
|
||||||
snapshotBinding.Price,
|
snapshotBinding);
|
||||||
order.ConsigneeCountry ?? string.Empty,
|
|
||||||
recipient?.Province,
|
|
||||||
recipient?.City,
|
|
||||||
recipient?.ZipCode);
|
|
||||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("价格分区未匹配(规则快照)");
|
|
||||||
}
|
|
||||||
|
|
||||||
var customer = order.CustomerId.HasValue
|
|
||||||
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await UpsertReceivableFeesAsync(
|
await UpsertReceivableFeesAsync(
|
||||||
order,
|
order,
|
||||||
snapshotBinding,
|
billing,
|
||||||
weightRow.ChargeableWeight ?? 0,
|
|
||||||
zoneKeys,
|
|
||||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
|
||||||
customer?.CustomerGroup,
|
|
||||||
chargingStage,
|
chargingStage,
|
||||||
replaceExistingReceivable: true);
|
replaceExistingReceivable: true);
|
||||||
|
|
||||||
@ -385,34 +368,24 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||||||
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
var billing = await ResolveOrderBillingContextAsync(
|
||||||
var recipient = recipients.FirstOrDefault();
|
order,
|
||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
weightRow.ChargeableWeight ?? 0,
|
||||||
snapshotBinding.Price,
|
snapshotBinding);
|
||||||
order.ConsigneeCountry ?? string.Empty,
|
|
||||||
recipient?.Province,
|
|
||||||
recipient?.City,
|
|
||||||
recipient?.ZipCode);
|
|
||||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("价格分区未匹配(规则快照)");
|
|
||||||
}
|
|
||||||
|
|
||||||
var (_, chargingStage) = ResolveReceivableBillingStage(order);
|
var (_, chargingStage) = ResolveReceivableBillingStage(order);
|
||||||
var customer = order.CustomerId.HasValue
|
|
||||||
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await ConfirmServiceChannelAndPayableAsync(
|
await ConfirmServiceChannelAndPayableAsync(
|
||||||
order,
|
order,
|
||||||
product,
|
product,
|
||||||
snapshotBinding,
|
billing,
|
||||||
zoneKeys,
|
chargingStage);
|
||||||
weightRow.ChargeableWeight ?? 0,
|
|
||||||
chargingStage,
|
if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
|
||||||
customer?.CustomerGroup);
|
{
|
||||||
|
order.OrderStatus = OrderStatusCodes.PendingPayment;
|
||||||
|
}
|
||||||
|
|
||||||
order.UpdateTime = DateTime.Now;
|
order.UpdateTime = DateTime.Now;
|
||||||
await OrderRepository.UpdateAsync(order);
|
await OrderRepository.UpdateAsync(order);
|
||||||
@ -487,7 +460,7 @@ public partial class OrderService
|
|||||||
private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) =>
|
private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) =>
|
||||||
order.OrderStatus switch
|
order.OrderStatus switch
|
||||||
{
|
{
|
||||||
OrderStatusCodes.Received or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip
|
OrderStatusCodes.Received or OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip
|
||||||
=> (OrderWeightTypeCodes.Receive, OrderFeeChargingStageCodes.Inbound),
|
=> (OrderWeightTypeCodes.Receive, OrderFeeChargingStageCodes.Inbound),
|
||||||
OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
|
OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
|
||||||
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed
|
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed
|
||||||
|
|||||||
@ -128,41 +128,29 @@ public partial class OrderService
|
|||||||
SyncWeightRows(weights, forecast);
|
SyncWeightRows(weights, forecast);
|
||||||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||||||
|
|
||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
|
||||||
|
var billing = await ResolveBillingContextAsync(
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
snapshotBinding,
|
||||||
snapshotBinding.Price,
|
new OrderBillingAddress(
|
||||||
country.CountryCode,
|
country.CountryCode,
|
||||||
input.Receiver?.Province,
|
input.Receiver?.Province,
|
||||||
input.Receiver?.City,
|
input.Receiver?.City,
|
||||||
input.Receiver?.ZipCode);
|
input.Receiver?.ZipCode),
|
||||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("价格分区未匹配(规则快照)");
|
|
||||||
}
|
|
||||||
|
|
||||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
|
|
||||||
await UpsertReceivableFeesAsync(
|
|
||||||
order,
|
|
||||||
snapshotBinding,
|
|
||||||
forecast.ChargeableWeight ?? 0,
|
forecast.ChargeableWeight ?? 0,
|
||||||
zoneKeys,
|
|
||||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
order.CargoAttribute ?? CargoAttributeCodes.General,
|
||||||
customer?.CustomerGroup,
|
order.WarehouseId,
|
||||||
OrderFeeChargingStageCodes.Forecast);
|
|
||||||
|
|
||||||
var downstream = weights.First(x => x.Type == OrderWeightTypeCodes.Downstream);
|
|
||||||
await ConfirmServiceChannelAndPayableAsync(
|
|
||||||
order,
|
|
||||||
product,
|
|
||||||
snapshotBinding,
|
|
||||||
zoneKeys,
|
|
||||||
downstream.ChargeableWeight ?? forecast.ChargeableWeight ?? 0,
|
|
||||||
OrderFeeChargingStageCodes.Forecast,
|
|
||||||
customer?.CustomerGroup);
|
customer?.CustomerGroup);
|
||||||
|
|
||||||
|
await UpsertReceivableFeesAsync(
|
||||||
|
order,
|
||||||
|
billing,
|
||||||
|
OrderFeeChargingStageCodes.Forecast);
|
||||||
|
|
||||||
|
// 创建订单不对客户暴露排货/买价:渠道与应付在入库等环节由运营侧处理。
|
||||||
order.OrderStatus = OrderStatusCodes.Predeclared;
|
order.OrderStatus = OrderStatusCodes.Predeclared;
|
||||||
order.PredeclareFailReason = string.Empty;
|
order.PredeclareFailReason = string.Empty;
|
||||||
|
order.AllocationPendingReason = string.Empty;
|
||||||
order.IsRemote = order.IsRemote;
|
order.IsRemote = order.IsRemote;
|
||||||
order.UpdateTime = DateTime.Now;
|
order.UpdateTime = DateTime.Now;
|
||||||
await OrderRepository.UpdateAsync(order);
|
await OrderRepository.UpdateAsync(order);
|
||||||
@ -194,7 +182,13 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string ParcelServiceNoPrefix = "ID";
|
private const string ParcelServiceNoPrefix = "IDE";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自动生成运单号:IDE + yyMMddHHmmss + 4 位随机数(如 IDE2607221859123456)。
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildParcelServiceNo(DateTime now, int randomSuffix) =>
|
||||||
|
$"{ParcelServiceNoPrefix}{now:yyMMddHHmmss}{randomSuffix}";
|
||||||
|
|
||||||
private static void NormalizeParcelCreateInput(OrderParcelCreateInput input)
|
private static void NormalizeParcelCreateInput(OrderParcelCreateInput input)
|
||||||
{
|
{
|
||||||
@ -202,6 +196,10 @@ public partial class OrderService
|
|||||||
input.ServiceNo = null;
|
input.ServiceNo = null;
|
||||||
input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim();
|
input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim();
|
||||||
input.RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim();
|
input.RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(input.CargoAttribute))
|
||||||
|
{
|
||||||
|
input.CargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoAttribute.Trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
|
private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
|
||||||
@ -234,8 +232,8 @@ public partial class OrderService
|
|||||||
throw new BusinessException("仓库ID不能为空");
|
throw new BusinessException("仓库ID不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue
|
if (!ParcelBillingValidators.HasFullDimensions(input.Length, input.Width, input.Height)
|
||||||
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || input.ActualWeight <= 0)
|
|| !input.ActualWeight.HasValue || input.ActualWeight <= 0)
|
||||||
{
|
{
|
||||||
throw new BusinessException("件重尺必须大于0");
|
throw new BusinessException("件重尺必须大于0");
|
||||||
}
|
}
|
||||||
@ -437,6 +435,141 @@ public partial class OrderService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveWarehouseNameAsync(int? warehouseId)
|
||||||
|
{
|
||||||
|
if (!warehouseId.HasValue || warehouseId.Value <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
|
||||||
|
return warehouse?.WarehouseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrderBillingAddress> ResolveOrderBillingAddressAsync(Order order)
|
||||||
|
{
|
||||||
|
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
||||||
|
var recipient = recipients.FirstOrDefault();
|
||||||
|
return new OrderBillingAddress(
|
||||||
|
order.ConsigneeCountry ?? string.Empty,
|
||||||
|
recipient?.Province,
|
||||||
|
recipient?.City,
|
||||||
|
recipient?.ZipCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>> ResolveZoneKeysOrThrowAsync(
|
||||||
|
SalesProduct product,
|
||||||
|
ProductSnapshotBinding snapshotBinding,
|
||||||
|
OrderBillingAddress address)
|
||||||
|
{
|
||||||
|
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||||||
|
product,
|
||||||
|
snapshotBinding.Rules,
|
||||||
|
snapshotBinding.Price,
|
||||||
|
address.CountryCode,
|
||||||
|
address.Province,
|
||||||
|
address.City,
|
||||||
|
address.ZipCode);
|
||||||
|
if (zoneKeys == null || zoneKeys.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("价格分区未匹配(规则快照)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return zoneKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CustomerGroup?> ResolveCustomerGroupAsync(int? customerId)
|
||||||
|
{
|
||||||
|
if (!customerId.HasValue || customerId.Value <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId.Value);
|
||||||
|
return customer?.CustomerGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析算价上下文(快照 + 分区 + 仓库 + 客户组)。供下单前或仅有地址时使用。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<OrderBillingContext> ResolveBillingContextAsync(
|
||||||
|
SalesProduct product,
|
||||||
|
ProductSnapshotBinding snapshotBinding,
|
||||||
|
OrderBillingAddress address,
|
||||||
|
decimal chargeableWeight,
|
||||||
|
string cargoAttribute,
|
||||||
|
int? warehouseId,
|
||||||
|
CustomerGroup? customerGroup = null)
|
||||||
|
{
|
||||||
|
if (chargeableWeight <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("计费重无效,无法计费");
|
||||||
|
}
|
||||||
|
|
||||||
|
var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, snapshotBinding, address);
|
||||||
|
var warehouseName = await ResolveWarehouseNameAsync(warehouseId);
|
||||||
|
return new OrderBillingContext
|
||||||
|
{
|
||||||
|
SnapshotBinding = snapshotBinding,
|
||||||
|
ZoneKeys = zoneKeys,
|
||||||
|
ChargeableWeight = chargeableWeight,
|
||||||
|
CargoAttribute = cargoAttribute,
|
||||||
|
CustomerGroup = customerGroup,
|
||||||
|
WarehouseId = warehouseId,
|
||||||
|
WarehouseName = warehouseName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析已有订单的算价上下文(绑定订单快照版本)。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<OrderBillingContext> ResolveOrderBillingContextAsync(
|
||||||
|
Order order,
|
||||||
|
SalesProduct product,
|
||||||
|
decimal chargeableWeight,
|
||||||
|
ProductSnapshotBinding? snapshotBinding = null)
|
||||||
|
{
|
||||||
|
if (chargeableWeight <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("计费重无效,无法计费");
|
||||||
|
}
|
||||||
|
|
||||||
|
var binding = snapshotBinding ?? await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||||||
|
var address = await ResolveOrderBillingAddressAsync(order);
|
||||||
|
var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, binding, address);
|
||||||
|
var customerGroup = await ResolveCustomerGroupAsync(order.CustomerId);
|
||||||
|
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
|
||||||
|
return new OrderBillingContext
|
||||||
|
{
|
||||||
|
SnapshotBinding = binding,
|
||||||
|
ZoneKeys = zoneKeys,
|
||||||
|
ChargeableWeight = chargeableWeight,
|
||||||
|
CargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General,
|
||||||
|
CustomerGroup = customerGroup,
|
||||||
|
WarehouseId = order.WarehouseId,
|
||||||
|
WarehouseName = warehouseName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertReceivableFeesAsync(
|
||||||
|
Order order,
|
||||||
|
OrderBillingContext billing,
|
||||||
|
string chargingStage,
|
||||||
|
bool replaceExistingReceivable = false)
|
||||||
|
{
|
||||||
|
await UpsertReceivableFeesAsync(
|
||||||
|
order,
|
||||||
|
billing.SnapshotBinding,
|
||||||
|
billing.ChargeableWeight,
|
||||||
|
billing.ZoneKeys,
|
||||||
|
billing.CargoAttribute,
|
||||||
|
billing.CustomerGroup,
|
||||||
|
chargingStage,
|
||||||
|
replaceExistingReceivable,
|
||||||
|
billing.WarehouseId,
|
||||||
|
billing.WarehouseName);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task UpsertReceivableFeesAsync(
|
private async Task UpsertReceivableFeesAsync(
|
||||||
Order order,
|
Order order,
|
||||||
ProductSnapshotBinding snapshotBinding,
|
ProductSnapshotBinding snapshotBinding,
|
||||||
@ -445,8 +578,15 @@ public partial class OrderService
|
|||||||
string cargoAttribute,
|
string cargoAttribute,
|
||||||
CustomerGroup? customerGroup,
|
CustomerGroup? customerGroup,
|
||||||
string chargingStage,
|
string chargingStage,
|
||||||
bool replaceExistingReceivable = false)
|
bool replaceExistingReceivable = false,
|
||||||
|
int? warehouseId = null,
|
||||||
|
string? warehouseName = null)
|
||||||
{
|
{
|
||||||
|
if (chargeableWeight <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("计费重无效,无法计算应收费用");
|
||||||
|
}
|
||||||
|
|
||||||
var fees = ParcelBillingCalculator.CalculateReceivableFees(
|
var fees = ParcelBillingCalculator.CalculateReceivableFees(
|
||||||
snapshotBinding.Price,
|
snapshotBinding.Price,
|
||||||
snapshotBinding.PriceVersion,
|
snapshotBinding.PriceVersion,
|
||||||
@ -455,11 +595,21 @@ public partial class OrderService
|
|||||||
zoneKeys,
|
zoneKeys,
|
||||||
cargoAttribute,
|
cargoAttribute,
|
||||||
customerGroup,
|
customerGroup,
|
||||||
chargingStage);
|
chargingStage,
|
||||||
|
warehouseId ?? order.WarehouseId,
|
||||||
|
warehouseName);
|
||||||
|
|
||||||
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
|
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
|
||||||
{
|
{
|
||||||
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格");
|
var detail = ParcelBillingCalculator.ExplainQuotePriceMiss(
|
||||||
|
snapshotBinding.Price,
|
||||||
|
chargeableWeight,
|
||||||
|
zoneKeys,
|
||||||
|
cargoAttribute,
|
||||||
|
customerGroup,
|
||||||
|
warehouseId ?? order.WarehouseId,
|
||||||
|
warehouseName);
|
||||||
|
throw new BusinessException($"产品卖价未匹配到有效价格:{detail}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fees.Count > 0)
|
if (fees.Count > 0)
|
||||||
@ -475,6 +625,7 @@ public partial class OrderService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
|
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
|
||||||
|
/// 仅用于入库、重算应付等内部环节;创建订单时不调用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ConfirmServiceChannelAndPayableAsync(
|
private async Task ConfirmServiceChannelAndPayableAsync(
|
||||||
Order order,
|
Order order,
|
||||||
@ -483,8 +634,14 @@ public partial class OrderService
|
|||||||
IReadOnlyList<string> zoneKeys,
|
IReadOnlyList<string> zoneKeys,
|
||||||
decimal chargeableWeight,
|
decimal chargeableWeight,
|
||||||
string chargingStage,
|
string chargingStage,
|
||||||
CustomerGroup? customerGroup)
|
CustomerGroup? _)
|
||||||
{
|
{
|
||||||
|
if (chargeableWeight <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("计费重无效,无法计算应付费用");
|
||||||
|
}
|
||||||
|
|
||||||
|
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
|
||||||
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
|
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
|
||||||
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
|
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
|
||||||
if (filtered.Count == 0)
|
if (filtered.Count == 0)
|
||||||
@ -514,8 +671,9 @@ public partial class OrderService
|
|||||||
chargeableWeight,
|
chargeableWeight,
|
||||||
zoneKeys,
|
zoneKeys,
|
||||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
order.CargoAttribute ?? CargoAttributeCodes.General,
|
||||||
customerGroup,
|
chargingStage,
|
||||||
chargingStage);
|
order.WarehouseId,
|
||||||
|
warehouseName);
|
||||||
if (previewFees.Count == 0)
|
if (previewFees.Count == 0)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@ -525,7 +683,8 @@ public partial class OrderService
|
|||||||
{
|
{
|
||||||
Provider = provider,
|
Provider = provider,
|
||||||
Channel = channel,
|
Channel = channel,
|
||||||
TotalPayableCost = previewFees.Sum(x => x.Amount)
|
TotalPayableCost = previewFees.Sum(x => x.Amount),
|
||||||
|
PayableFees = previewFees
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -534,34 +693,56 @@ public partial class OrderService
|
|||||||
throw new BusinessException("排货规则未匹配到有效渠道买价");
|
throw new BusinessException("排货规则未匹配到有效渠道买价");
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates);
|
var selectedCandidate = AllocationResolver.SelectCandidate(scheme, candidates);
|
||||||
var selectedCandidate = candidates.First(x => x.Channel.Id == selectedChannel.Id);
|
var payableFees = selectedCandidate.PayableFees.ToList();
|
||||||
var channelPriceBindingFinal = await ResolveChannelPriceBindingAsync(selectedChannel.Id, bindTime);
|
if (payableFees.Count == 0)
|
||||||
var payableFees = ParcelBillingCalculator.CalculatePayableFees(
|
|
||||||
channelPriceBindingFinal.Prices,
|
|
||||||
channelPriceBindingFinal.PriceVersion,
|
|
||||||
channelPriceBindingFinal.PriceEffectiveTime,
|
|
||||||
chargeableWeight,
|
|
||||||
zoneKeys,
|
|
||||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
|
||||||
customerGroup,
|
|
||||||
chargingStage);
|
|
||||||
|
|
||||||
if (payableFees.Count == 0
|
|
||||||
&& ParcelBillingCalculator.HasEnabledPayablePriceItems(channelPriceBindingFinal.Prices))
|
|
||||||
{
|
{
|
||||||
throw new BusinessException("渠道买价未配置或未匹配到有效价格");
|
throw new BusinessException("渠道买价未配置或未匹配到有效价格");
|
||||||
}
|
}
|
||||||
|
|
||||||
order.ChannelCode = selectedChannel.ChannelCode;
|
order.ChannelCode = selectedCandidate.Channel.ChannelCode;
|
||||||
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
|
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
|
||||||
order,
|
order,
|
||||||
selectedCandidate.Provider,
|
selectedCandidate.Provider,
|
||||||
selectedChannel);
|
selectedCandidate.Channel);
|
||||||
if (payableFees.Count > 0)
|
|
||||||
{
|
|
||||||
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
|
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
|
||||||
await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
|
await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
|
||||||
|
order.AllocationPendingReason = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task ConfirmServiceChannelAndPayableAsync(
|
||||||
|
Order order,
|
||||||
|
SalesProduct product,
|
||||||
|
OrderBillingContext billing,
|
||||||
|
string chargingStage) =>
|
||||||
|
ConfirmServiceChannelAndPayableAsync(
|
||||||
|
order,
|
||||||
|
product,
|
||||||
|
billing.SnapshotBinding,
|
||||||
|
billing.ZoneKeys,
|
||||||
|
billing.ChargeableWeight,
|
||||||
|
chargingStage,
|
||||||
|
billing.CustomerGroup);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> TryConfirmServiceChannelAndPayableAsync(
|
||||||
|
Order order,
|
||||||
|
SalesProduct product,
|
||||||
|
OrderBillingContext billing,
|
||||||
|
string chargingStage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ConfirmServiceChannelAndPayableAsync(order, product, billing, chargingStage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (BusinessException ex)
|
||||||
|
{
|
||||||
|
var message = ex.Message.Trim();
|
||||||
|
order.AllocationPendingReason = message.Length > 512 ? message[..512] : message;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -949,39 +1130,15 @@ public partial class OrderService
|
|||||||
throw new BusinessException("未配置计重进位");
|
throw new BusinessException("未配置计重进位");
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
|
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
||||||
var fromSnapshot = snapshotRanges is { Count: > 0 }
|
|
||||||
&& snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
|
||||||
IReadOnlyList<WeightRoundingInfo> ranges;
|
|
||||||
if (fromSnapshot)
|
|
||||||
{
|
|
||||||
ranges = RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId.Value);
|
|
||||||
if (rounding == null)
|
|
||||||
{
|
|
||||||
throw new BusinessException("未配置计重进位");
|
|
||||||
}
|
|
||||||
|
|
||||||
ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException) when (fromSnapshot)
|
|
||||||
{
|
|
||||||
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||||||
if (dbRanges.Count == 0)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, dbRanges);
|
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
||||||
}
|
rawWeight,
|
||||||
|
ranges,
|
||||||
|
rulesSnapshot,
|
||||||
|
weightRoundingId.Value,
|
||||||
|
dbRanges);
|
||||||
}
|
}
|
||||||
catch (BusinessException)
|
catch (BusinessException)
|
||||||
{
|
{
|
||||||
@ -993,6 +1150,135 @@ public partial class OrderService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<WeightRoundingInfo>> ResolveWeightRoundingRangesAsync(
|
||||||
|
SalesProduct product,
|
||||||
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
|
int weightRoundingId)
|
||||||
|
{
|
||||||
|
var snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
|
||||||
|
var fromSnapshot = snapshotRanges is { Count: > 0 }
|
||||||
|
&& snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
||||||
|
if (fromSnapshot)
|
||||||
|
{
|
||||||
|
return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
|
||||||
|
if (rounding == null)
|
||||||
|
{
|
||||||
|
throw new BusinessException("未配置计重进位");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ApplyRoundingWithSnapshotFallback(
|
||||||
|
decimal rawWeight,
|
||||||
|
IReadOnlyList<WeightRoundingInfo> ranges,
|
||||||
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
|
int weightRoundingId,
|
||||||
|
IReadOnlyList<WeightRoundingInfo> dbFallbackRanges)
|
||||||
|
{
|
||||||
|
var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
||||||
|
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0)
|
||||||
|
{
|
||||||
|
return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查价专用:无尺寸时仅按实重进位计计费重(不算体积重)。
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyQuoteChargeableWeightFromActualOnlyAsync(
|
||||||
|
SalesProduct product,
|
||||||
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||||||
|
OrderWeight forecast)
|
||||||
|
{
|
||||||
|
var actualWeight = forecast.ActualWeight ?? 0;
|
||||||
|
if (actualWeight <= 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException("货物重量无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
forecast.VolumetricWeight = 0;
|
||||||
|
forecast.Cubic = null;
|
||||||
|
|
||||||
|
var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot);
|
||||||
|
if (!weightRoundingId.HasValue)
|
||||||
|
{
|
||||||
|
throw new BusinessException("未配置计重进位");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
||||||
|
var dbRanges = ranges;
|
||||||
|
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
||||||
|
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)))
|
||||||
|
{
|
||||||
|
dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
||||||
|
actualWeight,
|
||||||
|
ranges,
|
||||||
|
rulesSnapshot,
|
||||||
|
weightRoundingId.Value,
|
||||||
|
dbRanges);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureBillingProductActive(SalesProduct product)
|
||||||
|
{
|
||||||
|
if (product.IsActive != EnableStatus.Enabled)
|
||||||
|
{
|
||||||
|
throw new BusinessException("订单关联的销售产品未启用,无法计费");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyMeasuredDimensions(
|
||||||
|
OrderWeight weightRow,
|
||||||
|
decimal length,
|
||||||
|
decimal width,
|
||||||
|
decimal height,
|
||||||
|
decimal actualWeight)
|
||||||
|
{
|
||||||
|
weightRow.Length = length;
|
||||||
|
weightRow.Width = width;
|
||||||
|
weightRow.Height = height;
|
||||||
|
weightRow.ActualWeight = actualWeight;
|
||||||
|
weightRow.Cubic = ParcelBillingCalculator.CalculateCubic(length, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyStageReceivableBillingAsync(
|
||||||
|
Order order,
|
||||||
|
SalesProduct product,
|
||||||
|
OrderWeight weightRow,
|
||||||
|
ProductSnapshotBinding snapshotBinding,
|
||||||
|
string chargingStage)
|
||||||
|
{
|
||||||
|
var billing = await ResolveOrderBillingContextAsync(
|
||||||
|
order,
|
||||||
|
product,
|
||||||
|
weightRow.ChargeableWeight ?? 0,
|
||||||
|
snapshotBinding);
|
||||||
|
|
||||||
|
await UpsertReceivableFeesAsync(
|
||||||
|
order,
|
||||||
|
billing,
|
||||||
|
chargingStage,
|
||||||
|
replaceExistingReceivable: true);
|
||||||
|
}
|
||||||
|
|
||||||
private static int? ResolveEffectiveWeightRuleId(
|
private static int? ResolveEffectiveWeightRuleId(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||||||
@ -1129,6 +1415,7 @@ public partial class OrderService
|
|||||||
IsIntercept = 0,
|
IsIntercept = 0,
|
||||||
IsRemote = 0,
|
IsRemote = 0,
|
||||||
PredeclareFailReason = string.Empty,
|
PredeclareFailReason = string.Empty,
|
||||||
|
AllocationPendingReason = string.Empty,
|
||||||
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
||||||
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
||||||
LabelTemplateId = product.LabelTemplateId,
|
LabelTemplateId = product.LabelTemplateId,
|
||||||
@ -1243,7 +1530,7 @@ public partial class OrderService
|
|||||||
{
|
{
|
||||||
for (var i = 0; i < 5; i++)
|
for (var i = 0; i < 5; i++)
|
||||||
{
|
{
|
||||||
var generated = $"{ParcelServiceNoPrefix}{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}";
|
var generated = BuildParcelServiceNo(DateTime.Now, Random.Shared.Next(1000, 9999));
|
||||||
if (!await OrderRepository.HasServiceNoAsync(generated))
|
if (!await OrderRepository.HasServiceNoAsync(generated))
|
||||||
{
|
{
|
||||||
return generated;
|
return generated;
|
||||||
@ -1276,11 +1563,7 @@ public partial class OrderService
|
|||||||
throw new BusinessException("运单号不能为空");
|
throw new BusinessException("运单号不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|
ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "收货");
|
||||||
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
|
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
|
||||||
if (order == null)
|
if (order == null)
|
||||||
@ -1293,10 +1576,7 @@ public partial class OrderService
|
|||||||
throw new BusinessException("订单未绑定销售产品,无法入库计费");
|
throw new BusinessException("订单未绑定销售产品,无法入库计费");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.OrderStatus is not (OrderStatusCodes.Predeclared or OrderStatusCodes.Received))
|
ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
|
||||||
{
|
|
||||||
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许入库收货");
|
|
||||||
}
|
|
||||||
|
|
||||||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||||||
if (product == null)
|
if (product == null)
|
||||||
@ -1304,6 +1584,8 @@ public partial class OrderService
|
|||||||
throw new BusinessException("订单关联的销售产品不存在");
|
throw new BusinessException("订单关联的销售产品不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureBillingProductActive(product);
|
||||||
|
|
||||||
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
||||||
.OrderBy(x => x.Id)
|
.OrderBy(x => x.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -1313,55 +1595,33 @@ public partial class OrderService
|
|||||||
throw new BusinessException("订单缺少收货件重尺记录");
|
throw new BusinessException("订单缺少收货件重尺记录");
|
||||||
}
|
}
|
||||||
|
|
||||||
receive.Length = length;
|
ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
|
||||||
receive.Width = width;
|
|
||||||
receive.Height = height;
|
|
||||||
receive.ActualWeight = actualWeight;
|
|
||||||
receive.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
|
|
||||||
|
|
||||||
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||||||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
|
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
|
||||||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||||||
|
|
||||||
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
var billing = await ResolveOrderBillingContextAsync(
|
||||||
var recipient = recipients.FirstOrDefault();
|
order,
|
||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
receive.ChargeableWeight ?? 0,
|
||||||
snapshotBinding.Price,
|
snapshotBinding);
|
||||||
order.ConsigneeCountry ?? string.Empty,
|
|
||||||
recipient?.Province,
|
|
||||||
recipient?.City,
|
|
||||||
recipient?.ZipCode);
|
|
||||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("价格分区未匹配(规则快照)");
|
|
||||||
}
|
|
||||||
|
|
||||||
var customer = order.CustomerId.HasValue
|
|
||||||
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await UpsertReceivableFeesAsync(
|
await UpsertReceivableFeesAsync(
|
||||||
order,
|
order,
|
||||||
snapshotBinding,
|
billing,
|
||||||
receive.ChargeableWeight ?? 0,
|
|
||||||
zoneKeys,
|
|
||||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
|
||||||
customer?.CustomerGroup,
|
|
||||||
OrderFeeChargingStageCodes.Inbound,
|
OrderFeeChargingStageCodes.Inbound,
|
||||||
replaceExistingReceivable: true);
|
replaceExistingReceivable: true);
|
||||||
|
|
||||||
await ConfirmServiceChannelAndPayableAsync(
|
var allocated = await TryConfirmServiceChannelAndPayableAsync(
|
||||||
order,
|
order,
|
||||||
product,
|
product,
|
||||||
snapshotBinding,
|
billing,
|
||||||
zoneKeys,
|
OrderFeeChargingStageCodes.Inbound);
|
||||||
receive.ChargeableWeight ?? 0,
|
|
||||||
OrderFeeChargingStageCodes.Inbound,
|
|
||||||
customer?.CustomerGroup);
|
|
||||||
|
|
||||||
order.OrderStatus = OrderStatusCodes.PendingPayment;
|
order.OrderStatus = allocated
|
||||||
|
? OrderStatusCodes.PendingPayment
|
||||||
|
: OrderStatusCodes.PendingAllocation;
|
||||||
order.ReceiveTime = DateTime.Now;
|
order.ReceiveTime = DateTime.Now;
|
||||||
order.UpdateTime = DateTime.Now;
|
order.UpdateTime = DateTime.Now;
|
||||||
await OrderRepository.UpdateAsync(order);
|
await OrderRepository.UpdateAsync(order);
|
||||||
@ -1382,11 +1642,7 @@ public partial class OrderService
|
|||||||
throw new BusinessException("运单号不能为空");
|
throw new BusinessException("运单号不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|
ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "发货");
|
||||||
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("发货实重与长宽高不能为空且须大于 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
|
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
|
||||||
if (order == null)
|
if (order == null)
|
||||||
@ -1421,6 +1677,8 @@ public partial class OrderService
|
|||||||
throw new BusinessException("订单关联的销售产品不存在");
|
throw new BusinessException("订单关联的销售产品不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureBillingProductActive(product);
|
||||||
|
|
||||||
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
||||||
.OrderBy(x => x.Id)
|
.OrderBy(x => x.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -1430,44 +1688,18 @@ public partial class OrderService
|
|||||||
throw new BusinessException("订单缺少发货件重尺记录");
|
throw new BusinessException("订单缺少发货件重尺记录");
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.Length = length;
|
ApplyMeasuredDimensions(ship, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
|
||||||
ship.Width = width;
|
|
||||||
ship.Height = height;
|
|
||||||
ship.ActualWeight = actualWeight;
|
|
||||||
ship.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
|
|
||||||
|
|
||||||
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||||||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship);
|
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship);
|
||||||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||||||
|
|
||||||
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
await ApplyStageReceivableBillingAsync(
|
||||||
var recipient = recipients.FirstOrDefault();
|
|
||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
|
||||||
product,
|
|
||||||
snapshotBinding.Rules,
|
|
||||||
snapshotBinding.Price,
|
|
||||||
order.ConsigneeCountry ?? string.Empty,
|
|
||||||
recipient?.Province,
|
|
||||||
recipient?.City,
|
|
||||||
recipient?.ZipCode);
|
|
||||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("价格分区未匹配(规则快照)");
|
|
||||||
}
|
|
||||||
|
|
||||||
var customer = order.CustomerId.HasValue
|
|
||||||
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await UpsertReceivableFeesAsync(
|
|
||||||
order,
|
order,
|
||||||
|
product,
|
||||||
|
ship,
|
||||||
snapshotBinding,
|
snapshotBinding,
|
||||||
ship.ChargeableWeight ?? 0,
|
OrderFeeChargingStageCodes.Outbound);
|
||||||
zoneKeys,
|
|
||||||
order.CargoAttribute ?? CargoAttributeCodes.General,
|
|
||||||
customer?.CustomerGroup,
|
|
||||||
OrderFeeChargingStageCodes.Outbound,
|
|
||||||
replaceExistingReceivable: true);
|
|
||||||
|
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
order.OrderStatus = OrderStatusCodes.PendingOnline;
|
order.OrderStatus = OrderStatusCodes.PendingOnline;
|
||||||
|
|||||||
@ -2,6 +2,8 @@ using Fake.Application;
|
|||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Domain.ChannelsAggregate.Entities;
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
using Oms.Domain.CustomersAggregate.Enums;
|
using Oms.Domain.CustomersAggregate.Enums;
|
||||||
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
using Oms.Domain.Shared.Enums;
|
using Oms.Domain.Shared.Enums;
|
||||||
using Oms.Domain.SystemAggregate.Entities;
|
using Oms.Domain.SystemAggregate.Entities;
|
||||||
|
|
||||||
@ -97,34 +99,62 @@ public partial class OrderService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record QuoteZoneAttempt(
|
||||||
|
IReadOnlyList<string> ZoneKeys,
|
||||||
|
bool IsZoneFallback,
|
||||||
|
string? ZoneName);
|
||||||
|
|
||||||
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
|
||||||
{
|
{
|
||||||
ValidatePriceCheckInput(input);
|
ValidatePriceCheckInput(input);
|
||||||
|
var profile = PriceCheckInputProfile.FromInput(input);
|
||||||
var bindTime = DateTime.Now;
|
var bindTime = DateTime.Now;
|
||||||
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
|
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
|
||||||
var cargoTargets = ResolvePriceCheckCargoTargets(input);
|
var cargoTargets = ResolvePriceCheckCargoTargets(input);
|
||||||
var warehouses = await ResolvePriceCheckWarehousesAsync(input);
|
var warehouses = await ResolvePriceCheckWarehousesAsync(input);
|
||||||
|
|
||||||
|
var dimensionLength = profile.HasDimensions ? input.Length!.Value : 0M;
|
||||||
|
var dimensionWidth = profile.HasDimensions ? input.Width!.Value : 0M;
|
||||||
|
var dimensionHeight = profile.HasDimensions ? input.Height!.Value : 0M;
|
||||||
|
|
||||||
var baseContext = new ParcelQuoteContext
|
var baseContext = new ParcelQuoteContext
|
||||||
{
|
{
|
||||||
CountryCode = countryCode,
|
CountryCode = countryCode,
|
||||||
Province = input.DestinationProvince,
|
Province = input.DestinationProvince,
|
||||||
PostalCode = input.PostalCode,
|
PostalCode = input.PostalCode,
|
||||||
Length = input.Length!.Value,
|
Length = dimensionLength,
|
||||||
Width = input.Width!.Value,
|
Width = dimensionWidth,
|
||||||
Height = input.Height!.Value,
|
Height = dimensionHeight,
|
||||||
ActualWeight = input.CargoWeight!.Value
|
ActualWeight = input.CargoWeight!.Value
|
||||||
};
|
};
|
||||||
|
|
||||||
var customerGroup = await ResolveCurrentCustomerGroupAsync();
|
var customerGroup = await ResolveCurrentCustomerGroupAsync();
|
||||||
var shippingKeyword = input.ShippingMethod?.Trim();
|
var shippingKeyword = input.ShippingMethod?.Trim();
|
||||||
var products = await SalesProductRepository.GetListAsync(x =>
|
List<SalesProduct> products;
|
||||||
|
if (string.IsNullOrWhiteSpace(shippingKeyword))
|
||||||
|
{
|
||||||
|
products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var exact = await SalesProductRepository.GetByProductCodeAsync(shippingKeyword);
|
||||||
|
if (exact != null && exact.IsActive == EnableStatus.Enabled)
|
||||||
|
{
|
||||||
|
products = [exact];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
products = await SalesProductRepository.GetListAsync(x =>
|
||||||
x.IsActive == EnableStatus.Enabled &&
|
x.IsActive == EnableStatus.Enabled &&
|
||||||
(string.IsNullOrWhiteSpace(shippingKeyword)
|
(x.ProductName.Contains(shippingKeyword) ||
|
||||||
|| x.ProductName.Contains(shippingKeyword)
|
x.ProductCode.Contains(shippingKeyword)));
|
||||||
|| x.ProductCode.Contains(shippingKeyword)));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var result = new OrderPriceCheckResultOutput();
|
var result = new OrderPriceCheckResultOutput
|
||||||
|
{
|
||||||
|
QueryHint = PriceCheckHintBuilder.BuildQueryHint(profile)
|
||||||
|
};
|
||||||
if (products.Count == 0)
|
if (products.Count == 0)
|
||||||
{
|
{
|
||||||
result.Failures.Add(new OrderPriceCheckFailureOutput
|
result.Failures.Add(new OrderPriceCheckFailureOutput
|
||||||
@ -161,6 +191,7 @@ public partial class OrderService
|
|||||||
var attempt = await TryComputePriceCheckQuoteAsync(
|
var attempt = await TryComputePriceCheckQuoteAsync(
|
||||||
product,
|
product,
|
||||||
quoteContext,
|
quoteContext,
|
||||||
|
profile,
|
||||||
customerGroup,
|
customerGroup,
|
||||||
bindTime,
|
bindTime,
|
||||||
cargo.DisplayName,
|
cargo.DisplayName,
|
||||||
@ -170,6 +201,7 @@ public partial class OrderService
|
|||||||
{
|
{
|
||||||
var quote = attempt.Quote!;
|
var quote = attempt.Quote!;
|
||||||
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
|
var summary = ParcelBillingCalculator.SummarizeQuoteFees(quote.FeeLines);
|
||||||
|
var itemHint = PriceCheckHintBuilder.BuildItemHint(profile, quote.UsedZoneFallback);
|
||||||
result.Items.Add(new OrderPriceCheckOutput
|
result.Items.Add(new OrderPriceCheckOutput
|
||||||
{
|
{
|
||||||
Id = quote.ProductId,
|
Id = quote.ProductId,
|
||||||
@ -191,7 +223,11 @@ public partial class OrderService
|
|||||||
TotalCostNum = summary.TotalCost,
|
TotalCostNum = summary.TotalCost,
|
||||||
RulesVersion = quote.RulesVersion,
|
RulesVersion = quote.RulesVersion,
|
||||||
PriceVersion = quote.PriceVersion,
|
PriceVersion = quote.PriceVersion,
|
||||||
IsRemote = quote.IsRemote
|
IsRemote = quote.IsRemote,
|
||||||
|
QuoteLevel = itemHint.QuoteLevel,
|
||||||
|
MissingFieldCodes = itemHint.MissingFieldCodes,
|
||||||
|
Disclaimers = itemHint.Disclaimers,
|
||||||
|
ZoneName = quote.ZoneName
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -222,7 +258,12 @@ public partial class OrderService
|
|||||||
|
|
||||||
if (codes.Count == 0)
|
if (codes.Count == 0)
|
||||||
{
|
{
|
||||||
codes.Add(string.Empty);
|
return
|
||||||
|
[
|
||||||
|
BuildCargoTarget(CargoAttributeCodes.General),
|
||||||
|
BuildCargoTarget(CargoAttributeCodes.Electronics),
|
||||||
|
BuildCargoTarget(CargoAttributeCodes.Sensitive)
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return codes.Select(code =>
|
return codes.Select(code =>
|
||||||
@ -233,6 +274,12 @@ public partial class OrderService
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PriceCheckCargoTarget BuildCargoTarget(string cargoAttribute) =>
|
||||||
|
new(
|
||||||
|
cargoAttribute,
|
||||||
|
ParcelBillingCalculator.GetCargoAttributeLabel(cargoAttribute),
|
||||||
|
cargoAttribute);
|
||||||
|
|
||||||
private async Task<List<WarehouseAddress>> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input)
|
private async Task<List<WarehouseAddress>> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input)
|
||||||
{
|
{
|
||||||
var ids = (input.WarehouseIds ?? [])
|
var ids = (input.WarehouseIds ?? [])
|
||||||
@ -262,11 +309,7 @@ public partial class OrderService
|
|||||||
throw new BusinessException("货物重量必须大于0");
|
throw new BusinessException("货物重量必须大于0");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
|
ParcelBillingValidators.EnsurePriceCheckDimensions(input.Length, input.Width, input.Height);
|
||||||
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException("长宽高必须大于0");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
|
private async Task<string> ResolveDestinationCountryCodeAsync(string destinationCountry)
|
||||||
@ -337,6 +380,7 @@ public partial class OrderService
|
|||||||
private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
|
private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
ParcelQuoteContext context,
|
ParcelQuoteContext context,
|
||||||
|
PriceCheckInputProfile profile,
|
||||||
CustomerGroup? customerGroup,
|
CustomerGroup? customerGroup,
|
||||||
DateTime bindTime,
|
DateTime bindTime,
|
||||||
string cargoTypeName,
|
string cargoTypeName,
|
||||||
@ -357,7 +401,8 @@ public partial class OrderService
|
|||||||
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
|
return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await IsBlacklistHitAsync(
|
if (profile.HasPostalCode
|
||||||
|
&& await IsBlacklistHitAsync(
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
snapshotBinding.Rules,
|
||||||
context.CountryCode,
|
context.CountryCode,
|
||||||
@ -373,89 +418,71 @@ public partial class OrderService
|
|||||||
warehouseName);
|
warehouseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
OrderWeight forecast;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (profile.HasDimensions)
|
||||||
|
{
|
||||||
|
forecast = ParcelBillingCalculator.BuildForecastWeight(context);
|
||||||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
forecast = new OrderWeight
|
||||||
|
{
|
||||||
|
Type = OrderWeightTypeCodes.Forecast,
|
||||||
|
ActualWeight = context.ActualWeight
|
||||||
|
};
|
||||||
|
await ApplyQuoteChargeableWeightFromActualOnlyAsync(product, snapshotBinding.Rules, forecast);
|
||||||
|
}
|
||||||
|
}
|
||||||
catch (BusinessException ex)
|
catch (BusinessException ex)
|
||||||
{
|
{
|
||||||
return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName);
|
return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
|
var zoneAttempts = await BuildQuoteZoneAttemptsAsync(product, snapshotBinding, context, profile);
|
||||||
if (!priceAreaId.HasValue)
|
if (zoneAttempts.Count == 0)
|
||||||
{
|
{
|
||||||
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
|
|
||||||
var snapshotZoneHint = snapshotZones.Count > 0
|
|
||||||
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}],"
|
|
||||||
: string.Empty;
|
|
||||||
return PriceCheckFailure(
|
return PriceCheckFailure(
|
||||||
product,
|
product,
|
||||||
"zone",
|
"zone",
|
||||||
$"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键",
|
profile.HasPostalCode
|
||||||
cargoTypeName,
|
? $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格分区"
|
||||||
warehouseName);
|
: $"目的国 {context.CountryCode} 未匹配到可参考的价格分区",
|
||||||
}
|
|
||||||
|
|
||||||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
|
||||||
product,
|
|
||||||
snapshotBinding.Rules,
|
|
||||||
snapshotBinding.Price,
|
|
||||||
context.CountryCode,
|
|
||||||
context.Province,
|
|
||||||
context.City,
|
|
||||||
context.PostalCode);
|
|
||||||
if (zoneKeys == null || zoneKeys.Count == 0)
|
|
||||||
{
|
|
||||||
var snapshotZones = ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price);
|
|
||||||
var snapshotZoneHint = snapshotZones.Count > 0
|
|
||||||
? $"卖价明细分区=[{string.Join(", ", snapshotZones)}]。"
|
|
||||||
: string.Empty;
|
|
||||||
return PriceCheckFailure(
|
|
||||||
product,
|
|
||||||
"zone",
|
|
||||||
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," +
|
|
||||||
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致",
|
|
||||||
cargoTypeName,
|
cargoTypeName,
|
||||||
warehouseName);
|
warehouseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
var chargeableWeight = forecast.ChargeableWeight ?? 0;
|
||||||
|
ParcelQuoteResult? bestQuote = null;
|
||||||
|
decimal bestTotal = decimal.MaxValue;
|
||||||
|
|
||||||
|
foreach (var zoneAttempt in zoneAttempts)
|
||||||
|
{
|
||||||
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
|
var feeLines = ParcelBillingCalculator.CalculateQuoteReceivableFeeLines(
|
||||||
snapshotBinding.Price,
|
snapshotBinding.Price,
|
||||||
snapshotBinding.PriceVersion,
|
snapshotBinding.PriceVersion,
|
||||||
snapshotBinding.PriceEffectiveTime,
|
snapshotBinding.PriceEffectiveTime,
|
||||||
chargeableWeight,
|
chargeableWeight,
|
||||||
zoneKeys,
|
zoneAttempt.ZoneKeys,
|
||||||
context.CargoAttribute,
|
context.CargoAttribute,
|
||||||
customerGroup,
|
customerGroup,
|
||||||
context.WarehouseId,
|
context.WarehouseId,
|
||||||
context.WarehouseName);
|
context.WarehouseName);
|
||||||
|
|
||||||
if (feeLines.Count == 0)
|
if (feeLines.Count == 0)
|
||||||
{
|
{
|
||||||
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = feeLines.Sum(x => x.Fee.Amount);
|
||||||
|
if (total >= bestTotal)
|
||||||
{
|
{
|
||||||
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return PriceCheckFailure(
|
var isRemote = profile.HasPostalCode
|
||||||
product,
|
&& await IsRemoteHitAsync(
|
||||||
"price",
|
|
||||||
ParcelBillingCalculator.ExplainQuotePriceMiss(
|
|
||||||
snapshotBinding.Price,
|
|
||||||
chargeableWeight,
|
|
||||||
zoneKeys,
|
|
||||||
context.CargoAttribute,
|
|
||||||
customerGroup,
|
|
||||||
context.WarehouseId,
|
|
||||||
context.WarehouseName),
|
|
||||||
cargoTypeName,
|
|
||||||
warehouseName);
|
|
||||||
}
|
|
||||||
|
|
||||||
var isRemote = await IsRemoteHitAsync(
|
|
||||||
product,
|
product,
|
||||||
snapshotBinding.Rules,
|
snapshotBinding.Rules,
|
||||||
context.CountryCode,
|
context.CountryCode,
|
||||||
@ -463,9 +490,8 @@ public partial class OrderService
|
|||||||
context.City,
|
context.City,
|
||||||
context.PostalCode);
|
context.PostalCode);
|
||||||
|
|
||||||
return new PriceCheckAttemptResult
|
bestTotal = total;
|
||||||
{
|
bestQuote = new ParcelQuoteResult
|
||||||
Quote = new ParcelQuoteResult
|
|
||||||
{
|
{
|
||||||
ProductId = product.Id,
|
ProductId = product.Id,
|
||||||
ProductCode = product.ProductCode,
|
ProductCode = product.ProductCode,
|
||||||
@ -474,13 +500,94 @@ public partial class OrderService
|
|||||||
PriceVersion = snapshotBinding.PriceVersion,
|
PriceVersion = snapshotBinding.PriceVersion,
|
||||||
VolumetricWeight = forecast.VolumetricWeight ?? 0,
|
VolumetricWeight = forecast.VolumetricWeight ?? 0,
|
||||||
ChargeableWeight = chargeableWeight,
|
ChargeableWeight = chargeableWeight,
|
||||||
ZoneName = zoneKeys[0],
|
ZoneName = zoneAttempt.ZoneName ?? zoneAttempt.ZoneKeys.FirstOrDefault(),
|
||||||
IsRemote = isRemote,
|
IsRemote = isRemote,
|
||||||
|
UsedZoneFallback = zoneAttempt.IsZoneFallback,
|
||||||
FeeLines = feeLines
|
FeeLines = feeLines
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bestQuote == null)
|
||||||
|
{
|
||||||
|
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
|
||||||
|
{
|
||||||
|
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallbackZoneKeys = zoneAttempts[0].ZoneKeys;
|
||||||
|
return PriceCheckFailure(
|
||||||
|
product,
|
||||||
|
"price",
|
||||||
|
ParcelBillingCalculator.ExplainQuotePriceMiss(
|
||||||
|
snapshotBinding.Price,
|
||||||
|
chargeableWeight,
|
||||||
|
fallbackZoneKeys,
|
||||||
|
context.CargoAttribute,
|
||||||
|
customerGroup,
|
||||||
|
context.WarehouseId,
|
||||||
|
context.WarehouseName),
|
||||||
|
cargoTypeName,
|
||||||
|
warehouseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PriceCheckAttemptResult { Quote = bestQuote };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<QuoteZoneAttempt>> BuildQuoteZoneAttemptsAsync(
|
||||||
|
SalesProduct product,
|
||||||
|
ProductSnapshotBinding snapshotBinding,
|
||||||
|
ParcelQuoteContext context,
|
||||||
|
PriceCheckInputProfile profile)
|
||||||
|
{
|
||||||
|
var attempts = new List<QuoteZoneAttempt>();
|
||||||
|
|
||||||
|
async Task TryAddAsync(string? province, string? zipCode, bool isFallback)
|
||||||
|
{
|
||||||
|
var keys = await ResolvePriceZoneKeysAsync(
|
||||||
|
product,
|
||||||
|
snapshotBinding.Rules,
|
||||||
|
snapshotBinding.Price,
|
||||||
|
context.CountryCode,
|
||||||
|
province,
|
||||||
|
context.City,
|
||||||
|
zipCode);
|
||||||
|
if (keys is not { Count: > 0 })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts.Any(x => x.ZoneKeys.SequenceEqual(keys, StringComparer.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.Add(new QuoteZoneAttempt(keys, isFallback, keys[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.HasPostalCode || profile.HasProvince)
|
||||||
|
{
|
||||||
|
await TryAddAsync(
|
||||||
|
profile.HasProvince ? context.Province : null,
|
||||||
|
profile.HasPostalCode ? context.PostalCode : null,
|
||||||
|
false);
|
||||||
|
return attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
await TryAddAsync(null, null, false);
|
||||||
|
|
||||||
|
foreach (var zoneKey in ParcelBillingCalculator.CollectDistinctDetailZoneKeys(snapshotBinding.Price))
|
||||||
|
{
|
||||||
|
if (attempts.Any(x => x.ZoneKeys.Any(k => string.Equals(k, zoneKey, StringComparison.OrdinalIgnoreCase))))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.Add(new QuoteZoneAttempt([zoneKey], true, zoneKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return attempts;
|
||||||
|
}
|
||||||
|
|
||||||
private static PriceCheckAttemptResult PriceCheckFailure(
|
private static PriceCheckAttemptResult PriceCheckFailure(
|
||||||
SalesProduct product,
|
SalesProduct product,
|
||||||
string step,
|
string step,
|
||||||
|
|||||||
@ -347,6 +347,16 @@ internal static class ParcelBillingCalculator
|
|||||||
RightPostcode = rule.RightPostcode
|
RightPostcode = rule.RightPostcode
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 卖价/买价明细匹配共用上下文(分区、货物属性、计费重、仓库)。
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct PriceDetailMatchContext(
|
||||||
|
IReadOnlyList<string> ZoneKeys,
|
||||||
|
string CargoAttribute,
|
||||||
|
decimal ChargeableWeight,
|
||||||
|
int? WarehouseId = null,
|
||||||
|
string? WarehouseName = null);
|
||||||
|
|
||||||
public static List<OrderFee> CalculateQuoteReceivableFees(
|
public static List<OrderFee> CalculateQuoteReceivableFees(
|
||||||
SalesProductPricePublishSnapshotDto priceSnapshot,
|
SalesProductPricePublishSnapshotDto priceSnapshot,
|
||||||
string priceVersion,
|
string priceVersion,
|
||||||
@ -622,7 +632,7 @@ internal static class ParcelBillingCalculator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 应付费用:买价全部启用项参与;ChargingStage 由件重尺阶段决定。
|
/// 应付费用:买价全部启用项参与;渠道买价不区分客户组。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<OrderFee> CalculatePayableFees(
|
public static List<OrderFee> CalculatePayableFees(
|
||||||
IReadOnlyList<ServiceChannelPriceListOutput> prices,
|
IReadOnlyList<ServiceChannelPriceListOutput> prices,
|
||||||
@ -631,8 +641,9 @@ internal static class ParcelBillingCalculator
|
|||||||
decimal chargeableWeight,
|
decimal chargeableWeight,
|
||||||
IReadOnlyList<string> zoneKeys,
|
IReadOnlyList<string> zoneKeys,
|
||||||
string cargoAttribute,
|
string cargoAttribute,
|
||||||
CustomerGroup? customerGroup,
|
string chargingStage,
|
||||||
string chargingStage)
|
int? warehouseId = null,
|
||||||
|
string? warehouseName = null)
|
||||||
{
|
{
|
||||||
var items = prices.Select(p => new PayableBillingPriceItem(
|
var items = prices.Select(p => new PayableBillingPriceItem(
|
||||||
p.Status, p.CostItem, p.Currency, p.Details));
|
p.Status, p.CostItem, p.Currency, p.Details));
|
||||||
@ -640,15 +651,12 @@ internal static class ParcelBillingCalculator
|
|||||||
items,
|
items,
|
||||||
priceVersion,
|
priceVersion,
|
||||||
priceEffectiveTime,
|
priceEffectiveTime,
|
||||||
chargeableWeight,
|
new PriceDetailMatchContext(zoneKeys, cargoAttribute, chargeableWeight, warehouseId, warehouseName),
|
||||||
zoneKeys,
|
|
||||||
cargoAttribute,
|
|
||||||
customerGroup,
|
|
||||||
chargingStage);
|
chargingStage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 预报阶段应付费用(买价快照,计费重取 downstream 行,调用方传入对应重量)。
|
/// 预报阶段应付费用(买价快照)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<OrderFee> CalculateForecastPayableFees(
|
public static List<OrderFee> CalculateForecastPayableFees(
|
||||||
IReadOnlyList<ServiceChannelPriceListOutput> prices,
|
IReadOnlyList<ServiceChannelPriceListOutput> prices,
|
||||||
@ -657,7 +665,8 @@ internal static class ParcelBillingCalculator
|
|||||||
decimal chargeableWeight,
|
decimal chargeableWeight,
|
||||||
string zoneName,
|
string zoneName,
|
||||||
string cargoAttribute,
|
string cargoAttribute,
|
||||||
CustomerGroup? customerGroup) =>
|
int? warehouseId = null,
|
||||||
|
string? warehouseName = null) =>
|
||||||
CalculatePayableFees(
|
CalculatePayableFees(
|
||||||
prices,
|
prices,
|
||||||
priceVersion,
|
priceVersion,
|
||||||
@ -665,8 +674,9 @@ internal static class ParcelBillingCalculator
|
|||||||
chargeableWeight,
|
chargeableWeight,
|
||||||
new[] { zoneName },
|
new[] { zoneName },
|
||||||
cargoAttribute,
|
cargoAttribute,
|
||||||
customerGroup,
|
OrderFeeChargingStageCodes.Forecast,
|
||||||
OrderFeeChargingStageCodes.Forecast);
|
warehouseId,
|
||||||
|
warehouseName);
|
||||||
|
|
||||||
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
|
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
|
||||||
HasEnabledReceivablePriceItems(priceSnapshot);
|
HasEnabledReceivablePriceItems(priceSnapshot);
|
||||||
@ -803,8 +813,14 @@ internal static class ParcelBillingCalculator
|
|||||||
CustomerGroup? customerGroup,
|
CustomerGroup? customerGroup,
|
||||||
string chargingStage,
|
string chargingStage,
|
||||||
int? warehouseId = null,
|
int? warehouseId = null,
|
||||||
string? warehouseName = null) =>
|
string? warehouseName = null)
|
||||||
CalculateReceivableFeeLines(
|
{
|
||||||
|
if (chargeableWeight <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return CalculateReceivableFeeLines(
|
||||||
priceItems,
|
priceItems,
|
||||||
priceVersion,
|
priceVersion,
|
||||||
priceEffectiveTime,
|
priceEffectiveTime,
|
||||||
@ -815,6 +831,7 @@ internal static class ParcelBillingCalculator
|
|||||||
chargingStage,
|
chargingStage,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
warehouseName).Select(x => x.Fee).ToList();
|
warehouseName).Select(x => x.Fee).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private static List<BillingFeeLine> CalculateReceivableFeeLines(
|
private static List<BillingFeeLine> CalculateReceivableFeeLines(
|
||||||
IEnumerable<ReceivableBillingPriceItem> priceItems,
|
IEnumerable<ReceivableBillingPriceItem> priceItems,
|
||||||
@ -885,38 +902,35 @@ internal static class ParcelBillingCalculator
|
|||||||
IEnumerable<PayableBillingPriceItem> priceItems,
|
IEnumerable<PayableBillingPriceItem> priceItems,
|
||||||
string priceVersion,
|
string priceVersion,
|
||||||
DateTime priceEffectiveTime,
|
DateTime priceEffectiveTime,
|
||||||
decimal chargeableWeight,
|
PriceDetailMatchContext match,
|
||||||
IReadOnlyList<string> zoneKeys,
|
string chargingStage)
|
||||||
string cargoAttribute,
|
|
||||||
CustomerGroup? customerGroup,
|
|
||||||
string chargingStage,
|
|
||||||
int? warehouseId = null,
|
|
||||||
string? warehouseName = null)
|
|
||||||
{
|
{
|
||||||
|
if (match.ChargeableWeight <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
var fees = new List<OrderFee>();
|
var fees = new List<OrderFee>();
|
||||||
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
|
foreach (var priceItem in priceItems.Where(x => EnableStatusRules.IsEnabled(x.Status)))
|
||||||
{
|
{
|
||||||
var detail = MatchPriceDetail(
|
var detail = MatchPriceDetail(
|
||||||
priceItem.Details,
|
priceItem.Details,
|
||||||
zoneKeys,
|
match.ZoneKeys,
|
||||||
cargoAttribute,
|
match.CargoAttribute,
|
||||||
chargeableWeight,
|
match.ChargeableWeight,
|
||||||
warehouseId,
|
match.WarehouseId,
|
||||||
warehouseName);
|
match.WarehouseName);
|
||||||
if (detail == null)
|
if (detail == null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var amount = CalculateDetailAmount(
|
var amount = CalculatePayableDetailAmount(priceItem.Details, detail, match);
|
||||||
priceItem.Details,
|
if (amount <= 0)
|
||||||
detail,
|
{
|
||||||
zoneKeys,
|
continue;
|
||||||
cargoAttribute,
|
}
|
||||||
chargeableWeight,
|
|
||||||
customerGroup,
|
|
||||||
warehouseId,
|
|
||||||
warehouseName);
|
|
||||||
fees.Add(new OrderFee
|
fees.Add(new OrderFee
|
||||||
{
|
{
|
||||||
Type = OrderFeeTypeCodes.Payable,
|
Type = OrderFeeTypeCodes.Payable,
|
||||||
@ -932,6 +946,118 @@ internal static class ParcelBillingCalculator
|
|||||||
return fees;
|
return fees;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 渠道买价明细金额:与下游服务商结算,仅使用 CalculatedValue / 续重配置,不区分客户组。
|
||||||
|
/// </summary>
|
||||||
|
private static decimal CalculatePayableDetailAmount(
|
||||||
|
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
|
||||||
|
ChannelPriceDetailOutput detail,
|
||||||
|
PriceDetailMatchContext match)
|
||||||
|
{
|
||||||
|
return NormalizeCalculationFormula(detail.CalculationFormula) switch
|
||||||
|
{
|
||||||
|
"unit" => CalculateBuyUnitPrice(detail, match.ChargeableWeight),
|
||||||
|
"total" => ResolveBuyPrice(detail),
|
||||||
|
"continue" => CalculateBuyContinueAmount(allDetails, detail, match),
|
||||||
|
_ => ResolveBuyPrice(detail)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ResolveBuyPrice(ChannelPriceDetailOutput detail) =>
|
||||||
|
detail.CalculatedValue ?? 0;
|
||||||
|
|
||||||
|
private static decimal CalculateBuyUnitPrice(ChannelPriceDetailOutput detail, decimal chargeableWeight)
|
||||||
|
{
|
||||||
|
var unitPrice = ResolveBuyPrice(detail);
|
||||||
|
return Math.Round(chargeableWeight * unitPrice, 4, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateBuyContinueAmount(
|
||||||
|
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
|
||||||
|
ChannelPriceDetailOutput detail,
|
||||||
|
PriceDetailMatchContext match)
|
||||||
|
{
|
||||||
|
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||||
|
if (TryGetWeightFactorText(factors, out var weightText)
|
||||||
|
&& TryParseWeightInterval(weightText, out var rangeStart, out _)
|
||||||
|
&& TryFindAdjacentBuyTotalBasePrice(allDetails, match, rangeStart, out var basePrice))
|
||||||
|
{
|
||||||
|
if (match.ChargeableWeight <= rangeStart)
|
||||||
|
{
|
||||||
|
return Math.Round(basePrice, 4, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
var continueUnitPrice = ResolveBuyPrice(detail);
|
||||||
|
return Math.Round(
|
||||||
|
basePrice + (match.ChargeableWeight - rangeStart) * continueUnitPrice,
|
||||||
|
4,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CalculateBuyContinuePrice(detail, match.ChargeableWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFindAdjacentBuyTotalBasePrice(
|
||||||
|
IReadOnlyList<ChannelPriceDetailOutput> allDetails,
|
||||||
|
PriceDetailMatchContext match,
|
||||||
|
decimal continueRangeStart,
|
||||||
|
out decimal basePrice)
|
||||||
|
{
|
||||||
|
basePrice = 0;
|
||||||
|
foreach (var candidate in allDetails)
|
||||||
|
{
|
||||||
|
var factors = ParseFactorDictionary(candidate.FactorValuesJson);
|
||||||
|
if (!ZoneMatches(factors, match.ZoneKeys)
|
||||||
|
|| !MatchCargoTypeFactor(factors, match.CargoAttribute)
|
||||||
|
|| !MatchWarehouseFactor(factors, match.WarehouseId, match.WarehouseName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NormalizeCalculationFormula(candidate.CalculationFormula) != "total")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetWeightFactorText(factors, out var weightText)
|
||||||
|
|| !TryParseWeightInterval(weightText, out _, out var rangeEnd))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeEnd != continueRangeStart)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
basePrice = ResolveBuyPrice(candidate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal CalculateBuyContinuePrice(
|
||||||
|
ChannelPriceDetailOutput detail,
|
||||||
|
decimal chargeableWeight)
|
||||||
|
{
|
||||||
|
var factors = ParseFactorDictionary(detail.FactorValuesJson);
|
||||||
|
var continuePricing = TryReadContinuePricing(factors, detail);
|
||||||
|
var firstWeight = continuePricing?.FirstWeight ?? 1M;
|
||||||
|
var firstPrice = continuePricing?.FirstPrice ?? ResolveBuyPrice(detail);
|
||||||
|
var continueUnit = continuePricing?.ContinueUnit ?? 0.5M;
|
||||||
|
var continuePrice = continuePricing?.ContinuePrice ?? firstPrice;
|
||||||
|
|
||||||
|
if (chargeableWeight <= firstWeight)
|
||||||
|
{
|
||||||
|
return Math.Round(firstPrice, 4, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
var overflow = chargeableWeight - firstWeight;
|
||||||
|
var continueUnits = Math.Ceiling(overflow / continueUnit);
|
||||||
|
return Math.Round(firstPrice + continueUnits * continuePrice, 4, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
private readonly record struct ReceivableBillingPriceItem(
|
private readonly record struct ReceivableBillingPriceItem(
|
||||||
byte Status,
|
byte Status,
|
||||||
string CostItem,
|
string CostItem,
|
||||||
|
|||||||
@ -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 IsRemote { get; init; }
|
||||||
|
|
||||||
|
public bool UsedZoneFallback { get; init; }
|
||||||
|
|
||||||
public IReadOnlyList<BillingFeeLine> FeeLines { 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.Application.Dtos.Channels;
|
||||||
using Oms.Domain.ChannelsAggregate.Consts;
|
using Oms.Domain.ChannelsAggregate.Consts;
|
||||||
using Oms.Domain.ChannelsAggregate.Entities;
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
|
||||||
namespace Oms.Application.Shared;
|
namespace Oms.Application.Shared;
|
||||||
|
|
||||||
@ -16,6 +17,9 @@ public static class AllocationResolver
|
|||||||
public ServiceChannel Channel { get; init; } = null!;
|
public ServiceChannel Channel { get; init; } = null!;
|
||||||
|
|
||||||
public decimal TotalPayableCost { get; init; }
|
public decimal TotalPayableCost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>候选渠道已算出的应付费用(选渠后可直接落库,避免重复算价)。</summary>
|
||||||
|
public IReadOnlyList<OrderFee> PayableFees { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders(
|
public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders(
|
||||||
@ -28,7 +32,10 @@ public static class AllocationResolver
|
|||||||
.OrderBy(x => x.SerialNumber)
|
.OrderBy(x => x.SerialNumber)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList<ChannelCandidate> candidates)
|
public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList<ChannelCandidate> candidates) =>
|
||||||
|
SelectCandidate(scheme, candidates).Channel;
|
||||||
|
|
||||||
|
public static ChannelCandidate SelectCandidate(string? scheme, IReadOnlyList<ChannelCandidate> candidates)
|
||||||
{
|
{
|
||||||
if (candidates.Count == 0)
|
if (candidates.Count == 0)
|
||||||
{
|
{
|
||||||
@ -44,18 +51,15 @@ public static class AllocationResolver
|
|||||||
AllocationSchemeCodes.TimeFastest => candidates
|
AllocationSchemeCodes.TimeFastest => candidates
|
||||||
.OrderBy(x => x.Channel.MinTime ?? int.MaxValue)
|
.OrderBy(x => x.Channel.MinTime ?? int.MaxValue)
|
||||||
.ThenBy(x => x.Provider.SerialNumber)
|
.ThenBy(x => x.Provider.SerialNumber)
|
||||||
.First()
|
.First(),
|
||||||
.Channel,
|
|
||||||
AllocationSchemeCodes.OverallBest => candidates
|
AllocationSchemeCodes.OverallBest => candidates
|
||||||
.OrderByDescending(x => x.Provider.Weight)
|
.OrderByDescending(x => x.Provider.Weight)
|
||||||
.ThenBy(x => x.Provider.SerialNumber)
|
.ThenBy(x => x.Provider.SerialNumber)
|
||||||
.First()
|
.First(),
|
||||||
.Channel,
|
|
||||||
_ => candidates
|
_ => candidates
|
||||||
.OrderBy(x => x.TotalPayableCost)
|
.OrderBy(x => x.TotalPayableCost)
|
||||||
.ThenBy(x => x.Provider.SerialNumber)
|
.ThenBy(x => x.Provider.SerialNumber)
|
||||||
.First()
|
.First()
|
||||||
.Channel
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,6 +160,7 @@ public static class OmsDocHelper
|
|||||||
OrderStatusCodes.Draft => "草稿",
|
OrderStatusCodes.Draft => "草稿",
|
||||||
OrderStatusCodes.Predeclared => "已预报",
|
OrderStatusCodes.Predeclared => "已预报",
|
||||||
OrderStatusCodes.Received => "已收货",
|
OrderStatusCodes.Received => "已收货",
|
||||||
|
OrderStatusCodes.PendingAllocation => "待排货",
|
||||||
OrderStatusCodes.PendingPayment => "待支付",
|
OrderStatusCodes.PendingPayment => "待支付",
|
||||||
OrderStatusCodes.PendingShip => "待发货",
|
OrderStatusCodes.PendingShip => "待发货",
|
||||||
OrderStatusCodes.PendingOnline => "待上网",
|
OrderStatusCodes.PendingOnline => "待上网",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ public static class OrderStatusCodes
|
|||||||
public const string Draft = "draft";
|
public const string Draft = "draft";
|
||||||
public const string Predeclared = "predeclared";
|
public const string Predeclared = "predeclared";
|
||||||
public const string Received = "received";
|
public const string Received = "received";
|
||||||
|
public const string PendingAllocation = "pendingAllocation";
|
||||||
public const string PendingPayment = "pendingPayment";
|
public const string PendingPayment = "pendingPayment";
|
||||||
public const string PendingShip = "pendingShip";
|
public const string PendingShip = "pendingShip";
|
||||||
public const string PendingOnline = "pendingOnline";
|
public const string PendingOnline = "pendingOnline";
|
||||||
|
|||||||
@ -48,6 +48,12 @@ public class Order : OmsAuditedAggregateRoot
|
|||||||
|
|
||||||
public string? PredeclareFailReason { get; set; }
|
public string? PredeclareFailReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 待排货原因:入库等环节排货或渠道买价未成功时写入,重算应付成功后清空。
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 512, IsNullable = true)]
|
||||||
|
public string? AllocationPendingReason { get; set; }
|
||||||
|
|
||||||
public string? SalesProductRulesVersion { get; set; }
|
public string? SalesProductRulesVersion { get; set; }
|
||||||
|
|
||||||
public string? SalesProductPriceVersion { get; set; }
|
public string? SalesProductPriceVersion { get; set; }
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Oms.Application.Dtos.Orders;
|
using Oms.Application.Dtos.Orders;
|
||||||
using Oms.Application.Messaging;
|
using Oms.Application.Messaging;
|
||||||
@ -23,11 +24,21 @@ public class OrderBatchJobCoordinatorGovernanceTests
|
|||||||
public OrderBatchJobCoordinatorGovernanceTests()
|
public OrderBatchJobCoordinatorGovernanceTests()
|
||||||
{
|
{
|
||||||
_publisher.SetupGet(x => x.IsEnabled).Returns(true);
|
_publisher.SetupGet(x => x.IsEnabled).Returns(true);
|
||||||
|
var batchOptions = Options.Create(new OrderBatchRabbitMqOptions());
|
||||||
|
var actionExecutor = new Mock<IOrderBatchActionExecutor>(MockBehavior.Loose);
|
||||||
|
var processor = new OrderBatchJobProcessor(
|
||||||
|
_batchJobRepository.Object,
|
||||||
|
_orderRepository.Object,
|
||||||
|
actionExecutor.Object,
|
||||||
|
batchOptions,
|
||||||
|
NullLogger<OrderBatchJobProcessor>.Instance);
|
||||||
_coordinator = new OrderBatchJobCoordinator(
|
_coordinator = new OrderBatchJobCoordinator(
|
||||||
_batchJobRepository.Object,
|
_batchJobRepository.Object,
|
||||||
_orderRepository.Object,
|
_orderRepository.Object,
|
||||||
_countryRepository.Object,
|
_countryRepository.Object,
|
||||||
_publisher.Object,
|
_publisher.Object,
|
||||||
|
processor,
|
||||||
|
batchOptions,
|
||||||
NullLogger<OrderBatchJobCoordinator>.Instance);
|
NullLogger<OrderBatchJobCoordinator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -129,8 +129,7 @@ public class ParcelBillingCalculatorDemoTests
|
|||||||
effectiveTime,
|
effectiveTime,
|
||||||
chargeableWeight,
|
chargeableWeight,
|
||||||
"美西",
|
"美西",
|
||||||
quoteContext.CargoAttribute,
|
quoteContext.CargoAttribute);
|
||||||
null);
|
|
||||||
|
|
||||||
var payableSummary = ParcelBillingCalculator.SummarizeFees(payableFees);
|
var payableSummary = ParcelBillingCalculator.SummarizeFees(payableFees);
|
||||||
|
|
||||||
|
|||||||
@ -302,8 +302,7 @@ public class ParcelBillingCalculatorTests
|
|||||||
_meta.EffectiveTime,
|
_meta.EffectiveTime,
|
||||||
(decimal)chargeableWeight,
|
(decimal)chargeableWeight,
|
||||||
Zone,
|
Zone,
|
||||||
"general",
|
"general");
|
||||||
null);
|
|
||||||
|
|
||||||
fees.Should().ContainSingle();
|
fees.Should().ContainSingle();
|
||||||
fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
|
fees[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
|
||||||
@ -326,8 +325,7 @@ public class ParcelBillingCalculatorTests
|
|||||||
_meta.EffectiveTime,
|
_meta.EffectiveTime,
|
||||||
1.2M,
|
1.2M,
|
||||||
Zone,
|
Zone,
|
||||||
"general",
|
"general");
|
||||||
null);
|
|
||||||
|
|
||||||
fees.Should().BeEmpty();
|
fees.Should().BeEmpty();
|
||||||
ParcelBillingCalculator.HasEnabledPayablePriceItems(prices).Should().BeFalse();
|
ParcelBillingCalculator.HasEnabledPayablePriceItems(prices).Should().BeFalse();
|
||||||
@ -345,7 +343,7 @@ public class ParcelBillingCalculatorTests
|
|||||||
var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees(
|
||||||
snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
|
snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
|
||||||
var payable = ParcelBillingCalculator.CalculateForecastPayableFees(
|
var payable = ParcelBillingCalculator.CalculateForecastPayableFees(
|
||||||
payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);
|
payablePrices, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general");
|
||||||
|
|
||||||
receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
|
receivable[0].Type.Should().Be(OrderFeeTypeCodes.Receivable);
|
||||||
payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
|
payable[0].Type.Should().Be(OrderFeeTypeCodes.Payable);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Status = 1,
|
Status = 2,
|
||||||
CostItem = "freight",
|
CostItem = "freight",
|
||||||
Details =
|
Details =
|
||||||
[
|
[
|
||||||
@ -34,7 +34,7 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Status = 1,
|
Status = 2,
|
||||||
CostItem = "handling",
|
CostItem = "handling",
|
||||||
Details = [Detail(28, 2, "total", "3", "0,10", 20)]
|
Details = [Detail(28, 2, "total", "3", "0,10", 20)]
|
||||||
})
|
})
|
||||||
@ -98,7 +98,7 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Status = 1,
|
Status = 2,
|
||||||
CostItem = "freight",
|
CostItem = "freight",
|
||||||
Details =
|
Details =
|
||||||
[
|
[
|
||||||
@ -109,7 +109,7 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Status = 1,
|
Status = 2,
|
||||||
CostItem = "handling",
|
CostItem = "handling",
|
||||||
Details = [Detail(44, 2, "total", "3", "(0-10]", 23)]
|
Details = [Detail(44, 2, "total", "3", "(0-10]", 23)]
|
||||||
})
|
})
|
||||||
@ -188,7 +188,7 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
Status = 1,
|
Status = 2,
|
||||||
CostItem = "freight",
|
CostItem = "freight",
|
||||||
Details =
|
Details =
|
||||||
[
|
[
|
||||||
@ -199,7 +199,7 @@ public class PriceCheckSnapshotVerificationTests
|
|||||||
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
Status = 1,
|
Status = 2,
|
||||||
CostItem = "handling",
|
CostItem = "handling",
|
||||||
Details = [Detail(38, 2, "total", "3", "(0-10]", 20)]
|
Details = [Detail(38, 2, "total", "3", "(0-10]", 20)]
|
||||||
})
|
})
|
||||||
|
|||||||
@ -29,7 +29,7 @@ internal static class ParcelBillingTestData
|
|||||||
params ChannelPriceDetailOutput[] details) =>
|
params ChannelPriceDetailOutput[] details) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Status = status,
|
Status = status == 0 ? (byte)0 : (byte)2,
|
||||||
CostItem = costItem,
|
CostItem = costItem,
|
||||||
Currency = "CNY",
|
Currency = "CNY",
|
||||||
Details = details.ToList()
|
Details = details.ToList()
|
||||||
@ -41,7 +41,7 @@ internal static class ParcelBillingTestData
|
|||||||
params ChannelPriceDetailOutput[] details) =>
|
params ChannelPriceDetailOutput[] details) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Status = status,
|
Status = status == 0 ? (byte)0 : (byte)2,
|
||||||
CostItem = costItem,
|
CostItem = costItem,
|
||||||
Currency = "CNY",
|
Currency = "CNY",
|
||||||
Details = details.ToList()
|
Details = details.ToList()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user