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