feat: remove ChargingTime and bill receivables by weight stage

Drop sell/buy price charging-time config; write receivable fees after forecast or receive chargeable weight, hook inbound billing, fix order list status filter, and add DB migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 16:24:53 +08:00
parent 91ba6d5d59
commit 5e26de6ff3
27 changed files with 484 additions and 168 deletions

2
db.sql
View File

@ -336,7 +336,6 @@ CREATE TABLE `t_sales_product_price` (
`CostItem` VARCHAR(64) NOT NULL COMMENT '费用项',
`CostUnit` VARCHAR(16) NOT NULL COMMENT '费用单位:kg/票/件',
`Currency` VARCHAR(16) NOT NULL COMMENT '币种:RMB/USD/EUR',
`ChargingTime` VARCHAR(32) NULL COMMENT '收费时机:预报/出库/签收',
`ChargingCondition` VARCHAR(64) NULL COMMENT '收费条件',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
@ -466,7 +465,6 @@ CREATE TABLE `t_service_channel_price` (
`CostItem` VARCHAR(64) NOT NULL COMMENT '费用项',
`CostUnit` VARCHAR(16) NOT NULL COMMENT '费用单位:kg/票/件',
`Currency` VARCHAR(16) NOT NULL COMMENT '币种:RMB/USD/EUR',
`ChargingTime` VARCHAR(32) NULL COMMENT '收费时机:预报/出库/签收',
`ChargingCondition` VARCHAR(64) NULL COMMENT '收费条件',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',

View File

@ -0,0 +1,3 @@
-- 移除卖价/买价主表「收费时机」字段(已由件重尺阶段触发算价,不再配置 ChargingTime)
ALTER TABLE `t_sales_product_price` DROP COLUMN IF EXISTS `ChargingTime`;
ALTER TABLE `t_service_channel_price` DROP COLUMN IF EXISTS `ChargingTime`;

View File

@ -45,11 +45,6 @@ public class SalesProductPriceListOutput
/// </summary>
public string Currency { get; set; } = string.Empty;
/// <summary>
/// 计费时机。
/// </summary>
public string? ChargingTime { get; set; }
/// <summary>
/// 计费条件。
/// </summary>

View File

@ -47,11 +47,6 @@ public class SalesProductPriceSaveInput
/// </summary>
public string Currency { get; set; } = string.Empty;
/// <summary>
/// 计费时机。
/// </summary>
public string? ChargingTime { get; set; }
/// <summary>
/// 计费条件。
/// </summary>

View File

@ -45,11 +45,6 @@ public class ServiceChannelPriceListOutput
/// </summary>
public string Currency { get; set; } = string.Empty;
/// <summary>
/// 计费时机。
/// </summary>
public string? ChargingTime { get; set; }
/// <summary>
/// 计费条件。
/// </summary>

View File

@ -47,11 +47,6 @@ public class ServiceChannelPriceSaveInput
/// </summary>
public string Currency { get; set; } = string.Empty;
/// <summary>
/// 计费时机。
/// </summary>
public string? ChargingTime { get; set; }
/// <summary>
/// 计费条件。
/// </summary>

View File

@ -6,22 +6,26 @@ namespace Oms.Application.Dtos.Orders;
public class OrderExportInput
{
/// <summary>
/// 状态。
/// 状态:all 或 OrderStatusCodes 枚举值。
/// </summary>
[Newtonsoft.Json.JsonProperty("status")]
public string? Status { get; set; }
/// <summary>
/// 关键词。
/// </summary>
[Newtonsoft.Json.JsonProperty("keyword")]
public string? Keyword { get; set; }
/// <summary>
/// 创建TimeStart。
/// </summary>
[Newtonsoft.Json.JsonProperty("createTimeStart")]
public DateTime? CreateTimeStart { get; set; }
/// <summary>
/// 创建TimeEnd。
/// </summary>
[Newtonsoft.Json.JsonProperty("createTimeEnd")]
public DateTime? CreateTimeEnd { get; set; }
}

View File

@ -8,30 +8,36 @@ public class OrderPaginatedInput
/// <summary>
/// 页码,从 1 开始。
/// </summary>
[Newtonsoft.Json.JsonProperty("page")]
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
[Newtonsoft.Json.JsonProperty("pageSize")]
public int PageSize { get; set; } = 20;
/// <summary>
/// 状态。
/// 状态:all 或 OrderStatusCodes 枚举值。
/// </summary>
[Newtonsoft.Json.JsonProperty("status")]
public string? Status { get; set; }
/// <summary>
/// 关键词。
/// </summary>
[Newtonsoft.Json.JsonProperty("keyword")]
public string? Keyword { get; set; }
/// <summary>
/// 创建TimeStart。
/// </summary>
[Newtonsoft.Json.JsonProperty("createTimeStart")]
public DateTime? CreateTimeStart { get; set; }
/// <summary>
/// 创建TimeEnd。
/// </summary>
[Newtonsoft.Json.JsonProperty("createTimeEnd")]
public DateTime? CreateTimeEnd { get; set; }
}

View File

@ -84,8 +84,6 @@ public class SalesProductPriceSnapshotItemDto
public string Currency { get; set; } = string.Empty;
public string? ChargingTime { get; set; }
public string? ChargingCondition { get; set; }
public List<ChannelPriceFactorOutput> Factors { get; set; } = [];

View File

@ -84,7 +84,6 @@ public partial class SalesProductService
CostItem = price.CostItem,
CostUnit = price.CostUnit,
Currency = price.Currency,
ChargingTime = price.ChargingTime,
ChargingCondition = price.ChargingCondition,
Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [],
Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [],
@ -423,7 +422,6 @@ public partial class SalesProductService
CostItem = output.CostItem,
CostUnit = output.CostUnit,
Currency = output.Currency,
ChargingTime = output.ChargingTime,
ChargingCondition = output.ChargingCondition,
Factors = output.Factors.Select(item => new ChannelPriceFactorInput
{
@ -455,7 +453,6 @@ public partial class SalesProductService
CostItem = output.CostItem,
CostUnit = output.CostUnit,
Currency = output.Currency,
ChargingTime = output.ChargingTime,
ChargingCondition = output.ChargingCondition,
Factors = output.Factors.Select(item => new ChannelPriceFactorInput
{

View File

@ -197,7 +197,6 @@ public partial class SalesProductService : ApplicationService
CostItem = input.CostItem,
CostUnit = input.CostUnit,
Currency = input.Currency,
ChargingTime = input.ChargingTime,
ChargingCondition = input.ChargingCondition
},
BuildSalesProductPriceFactors(input.Factors),
@ -230,7 +229,6 @@ public partial class SalesProductService : ApplicationService
price.CostItem = input.CostItem;
price.CostUnit = input.CostUnit;
price.Currency = input.Currency;
price.ChargingTime = input.ChargingTime;
price.ChargingCondition = input.ChargingCondition;
await SalesProductRepository.UpdatePriceAsync(
price,
@ -308,7 +306,6 @@ public partial class SalesProductService : ApplicationService
CostItem = priceInput.CostItem,
CostUnit = priceInput.CostUnit,
Currency = priceInput.Currency,
ChargingTime = priceInput.ChargingTime,
ChargingCondition = priceInput.ChargingCondition
});
factors.AddRange(BuildSalesProductPriceFactors(priceInput.Factors, currentTempId));

View File

@ -136,7 +136,6 @@ public partial class ServiceChannelService
CostItem = price.CostItem,
CostUnit = price.CostUnit,
Currency = price.Currency,
ChargingTime = price.ChargingTime,
ChargingCondition = price.ChargingCondition,
Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [],
Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [],
@ -392,7 +391,6 @@ public partial class ServiceChannelService
CostItem = output.CostItem,
CostUnit = output.CostUnit,
Currency = output.Currency,
ChargingTime = output.ChargingTime,
ChargingCondition = output.ChargingCondition,
Factors = output.Factors.Select(item => new ChannelPriceFactorInput
{

View File

@ -284,7 +284,6 @@ public partial class ServiceChannelService : ApplicationService
CostItem = input.CostItem,
CostUnit = input.CostUnit,
Currency = input.Currency,
ChargingTime = input.ChargingTime,
ChargingCondition = input.ChargingCondition
},
BuildPriceFactors(input.Factors),
@ -317,7 +316,6 @@ public partial class ServiceChannelService : ApplicationService
price.CostItem = input.CostItem;
price.CostUnit = input.CostUnit;
price.Currency = input.Currency;
price.ChargingTime = input.ChargingTime;
price.ChargingCondition = input.ChargingCondition;
await ServiceChannelRepository.UpdatePriceAsync(
price,
@ -395,7 +393,6 @@ public partial class ServiceChannelService : ApplicationService
CostItem = priceInput.CostItem,
CostUnit = priceInput.CostUnit,
Currency = priceInput.Currency,
ChargingTime = priceInput.ChargingTime,
ChargingCondition = priceInput.ChargingCondition
});
factors.AddRange(BuildPriceFactors(priceInput.Factors, currentTempId));

View File

@ -2,6 +2,7 @@ using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Operations;
using Oms.Application.Dtos.Shared;
using Oms.Application.Services.Orders;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OperationsAggregate.Entities;
using Oms.Domain.OperationsAggregate.Repositories;
@ -18,6 +19,7 @@ public class InboundRecordService : ApplicationService
private IInboundRecordRepository InboundRecordRepository => LazyServiceProvider.GetRequiredService<IInboundRecordRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
/// <summary>
/// 新增入库记录。
@ -50,6 +52,12 @@ public class InboundRecordService : ApplicationService
};
var savedEntity = await InboundRecordRepository.InsertAsync(entity);
await OrderService.ApplyReceiveBillingByWaybillAsync(
input.WaybillNo,
input.Length,
input.Width,
input.Height,
input.Weight);
return ObjectMapper.Map<InboundRecord, InboundRecordOutput>(savedEntity);
}

View File

@ -102,24 +102,14 @@ public partial class OrderService
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
forecast.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price))
{
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格");
}
if (fees.Count > 0)
{
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Forecast);
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
@ -289,6 +279,116 @@ public partial class OrderService
};
}
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
{
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
SalesProductRulesPublishSnapshotDto rulesSnapshot;
string rulesVersion;
DateTime rulesEffectiveTime;
if (string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
|| order.SalesProductRulesVersion.StartsWith("LIVE-", StringComparison.OrdinalIgnoreCase))
{
rulesSnapshot = await BuildLiveRulesSnapshotAsync(product);
rulesVersion = order.SalesProductRulesVersion ?? $"LIVE-{product.Id}";
rulesEffectiveTime = bindTime;
}
else
{
var rulesRecord = await SalesProductRepository.GetPublishRecordByVersionAsync(
product.Id,
RulesOperatorType,
order.SalesProductRulesVersion);
if (rulesRecord?.Content == null)
{
throw new BusinessException("未命中订单绑定的规则快照版本");
}
rulesSnapshot = OmsDocHelper.DeserializeRulesSnapshot(rulesRecord.Content)
?? throw new BusinessException("未命中订单绑定的规则快照版本");
rulesVersion = rulesRecord.Version;
rulesEffectiveTime = rulesRecord.PublishTime;
}
SalesProductPricePublishSnapshotDto priceSnapshot;
string priceVersion;
DateTime priceEffectiveTime;
if (string.IsNullOrWhiteSpace(order.SalesProductPriceVersion)
|| order.SalesProductPriceVersion.StartsWith("LIVE-", StringComparison.OrdinalIgnoreCase))
{
priceSnapshot = await BuildLivePriceSnapshotAsync(product);
if (priceSnapshot.Prices.Count == 0)
{
throw new BusinessException("未命中订单绑定的价格快照版本");
}
priceVersion = order.SalesProductPriceVersion ?? $"LIVE-{product.Id}";
priceEffectiveTime = bindTime;
}
else
{
var priceRecord = await SalesProductRepository.GetPublishRecordByVersionAsync(
product.Id,
PriceOperatorType,
order.SalesProductPriceVersion);
if (priceRecord?.Content == null)
{
throw new BusinessException("未命中订单绑定的价格快照版本");
}
priceSnapshot = OmsDocHelper.DeserializePriceSnapshot(priceRecord.Content)
?? throw new BusinessException("未命中订单绑定的价格快照版本");
if (priceSnapshot.Prices.Count == 0)
{
throw new BusinessException("未命中订单绑定的价格快照版本");
}
priceVersion = priceRecord.Version;
priceEffectiveTime = priceRecord.PublishTime;
}
return new ProductSnapshotBinding
{
RulesVersion = rulesVersion,
PriceVersion = priceVersion,
RulesEffectiveTime = rulesEffectiveTime,
PriceEffectiveTime = priceEffectiveTime,
Rules = rulesSnapshot,
Price = priceSnapshot
};
}
private async Task UpsertReceivableFeesAsync(
Order order,
ProductSnapshotBinding snapshotBinding,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage)
{
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
chargingStage);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
{
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格");
}
if (fees.Count > 0)
{
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
}
private async Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
{
var snapshot = new SalesProductRulesPublishSnapshotDto
@ -340,7 +440,6 @@ public partial class OrderService
CostItem = price.CostItem,
CostUnit = price.CostUnit,
Currency = price.Currency,
ChargingTime = price.ChargingTime,
ChargingCondition = price.ChargingCondition,
Factors = factorMap.TryGetValue(price.Id, out var factors)
? factors.Select(MapPriceFactorOutput).ToList()
@ -738,6 +837,26 @@ public partial class OrderService
}
}
private static void SyncReceiveToShipAndDownstream(List<OrderWeight> weights, OrderWeight receive)
{
foreach (var type in new[] { OrderWeightTypeCodes.Ship, OrderWeightTypeCodes.Downstream })
{
var row = weights.FirstOrDefault(x => x.Type == type);
if (row == null)
{
continue;
}
row.Length = receive.Length;
row.Width = receive.Width;
row.Height = receive.Height;
row.ActualWeight = receive.ActualWeight;
row.VolumetricWeight = receive.VolumetricWeight;
row.ChargeableWeight = receive.ChargeableWeight;
row.Cubic = receive.Cubic;
}
}
private static Order BuildDraftOrderEntity(
OrderParcelCreateInput input,
SalesProduct product,
@ -908,6 +1027,122 @@ public partial class OrderService
await OrderRepository.UpdateAsync(order);
}
/// <summary>
/// 入库收货后:覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并同步 ship/downstream 重量行。
/// </summary>
internal async Task ApplyReceiveBillingByWaybillAsync(
string waybillNo,
decimal? length,
decimal? width,
decimal? height,
decimal? actualWeight)
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
return;
}
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
return;
}
var order = await OrderRepository.GetByServiceNoAsync(waybillNo.Trim());
if (order == null || string.IsNullOrWhiteSpace(order.ProductCode))
{
return;
}
if (order.OrderStatus is not (OrderStatusCodes.Predeclared or OrderStatusCodes.Received))
{
return;
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
return;
}
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
if (receive == null)
{
return;
}
receive.Length = length;
receive.Width = width;
receive.Height = height;
receive.ActualWeight = actualWeight;
receive.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
ProductSnapshotBinding snapshotBinding;
try
{
snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
}
catch (BusinessException)
{
return;
}
try
{
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
}
catch (BusinessException)
{
return;
}
SyncReceiveToShipAndDownstream(weights, receive);
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)
{
return;
}
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
try
{
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
receive.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Inbound);
}
catch (BusinessException)
{
return;
}
order.OrderStatus = OrderStatusCodes.Received;
order.ReceiveTime = DateTime.Now;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
{
var mapped = (await MapOrderOutputsAsync([order])).First();

View File

@ -171,7 +171,7 @@ public partial class OrderService
}
/// <summary>
/// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细(忽略收费时机)。
/// 查价试算:按国家+邮编匹配分区,在卖价快照中匹配明细。
/// </summary>
private async Task<PriceCheckAttemptResult> TryComputePriceCheckQuoteAsync(
SalesProduct product,

View File

@ -40,7 +40,7 @@ public partial class OrderService : ApplicationService
/// 3. 按接口约定返回列表结果。
/// </remarks>
[HttpPost("/order/list")]
public async Task<OmsPageResult<OrderOutput>> GetPaginatedListAsync(OrderPaginatedInput input)
public async Task<OmsPageResult<OrderOutput>> GetPaginatedListAsync([FromBody] OrderPaginatedInput input)
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
@ -204,7 +204,7 @@ public partial class OrderService : ApplicationService
/// 3. 以文件形式返回结果。
/// </remarks>
[HttpPost("/order/export")]
public async Task<OrderExportOutput> ExportAsync(OrderExportInput input)
public async Task<OrderExportOutput> ExportAsync([FromBody] OrderExportInput input)
{
await CleanupExpiredFilesAsync(GetOrderExportDirectory());
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
@ -473,7 +473,9 @@ public partial class OrderService : ApplicationService
{
status = status?.Trim();
keyword = keyword?.Trim();
var hasStatusFilter = !string.IsNullOrWhiteSpace(status) && status != "all";
var statusValue = !string.IsNullOrWhiteSpace(status) && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)
? status
: null;
var hasKeywordFilter = !string.IsNullOrWhiteSpace(keyword);
var hasCustomerFilter = customerId.HasValue;
var customerIdValue = customerId.GetValueOrDefault();
@ -485,7 +487,7 @@ public partial class OrderService : ApplicationService
var end = createTimeEnd.Value;
return order =>
(!hasCustomerFilter || order.CustomerId == customerIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(statusValue == null || order.OrderStatus == statusValue) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
(order.ClientNo ?? string.Empty).Contains(keyword!) ||
@ -499,7 +501,7 @@ public partial class OrderService : ApplicationService
var start = createTimeStart.Value;
return order =>
(!hasCustomerFilter || order.CustomerId == customerIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(statusValue == null || order.OrderStatus == statusValue) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
(order.ClientNo ?? string.Empty).Contains(keyword!) ||
@ -512,7 +514,7 @@ public partial class OrderService : ApplicationService
var end = createTimeEnd.Value;
return order =>
(!hasCustomerFilter || order.CustomerId == customerIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(statusValue == null || order.OrderStatus == statusValue) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
(order.ClientNo ?? string.Empty).Contains(keyword!) ||
@ -522,7 +524,7 @@ public partial class OrderService : ApplicationService
return order =>
(!hasCustomerFilter || order.CustomerId == customerIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(statusValue == null || order.OrderStatus == statusValue) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
(order.ClientNo ?? string.Empty).Contains(keyword!) ||

View File

@ -353,23 +353,21 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup)
{
var items = priceSnapshot.Prices.Select(p => new BillingPriceItem(
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
return CalculateForecastFees(
items,
OrderFeeTypeCodes.Receivable,
CustomerGroup? customerGroup) =>
CalculateReceivableFees(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
onlyForecastChargingTime: false);
}
OrderFeeChargingStageCodes.Forecast);
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot);
public static bool HasEnabledReceivablePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => x.Status == 1);
/// <summary>
@ -510,14 +508,15 @@ internal static class ParcelBillingCalculator
string zoneName,
string cargoAttribute,
CustomerGroup? customerGroup) =>
CalculateForecastReceivableFees(
CalculateReceivableFees(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
new[] { zoneName },
cargoAttribute,
customerGroup);
customerGroup,
OrderFeeChargingStageCodes.Forecast);
public static List<OrderFee> CalculateForecastReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
@ -526,20 +525,67 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup)
{
var items = priceSnapshot.Prices.Select(p => new BillingPriceItem(
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
return CalculateForecastFees(
items,
OrderFeeTypeCodes.Receivable,
CustomerGroup? customerGroup) =>
CalculateReceivableFees(
priceSnapshot,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
onlyForecastChargingTime: true);
OrderFeeChargingStageCodes.Forecast);
/// <summary>
/// 应收费用:卖价全部启用项参与;ChargingStage 由件重尺阶段决定。
/// </summary>
public static List<OrderFee> CalculateReceivableFees(
SalesProductPricePublishSnapshotDto priceSnapshot,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage)
{
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details));
return CalculateReceivableFeesFromItems(
items,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
chargingStage);
}
/// <summary>
/// 应付费用:买价全部启用项参与;ChargingStage 由件重尺阶段决定。
/// </summary>
public static List<OrderFee> CalculatePayableFees(
IReadOnlyList<ServiceChannelPriceListOutput> prices,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage)
{
var items = prices.Select(p => new PayableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details));
return CalculatePayableFeesFromItems(
items,
priceVersion,
priceEffectiveTime,
chargeableWeight,
zoneKeys,
cargoAttribute,
customerGroup,
chargingStage);
}
/// <summary>
@ -552,44 +598,25 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight,
string zoneName,
string cargoAttribute,
CustomerGroup? customerGroup)
{
var items = prices.Select(p => new BillingPriceItem(
p.Status, p.ChargingTime, p.CostItem, p.Currency, p.Details));
return CalculateForecastFees(
items,
OrderFeeTypeCodes.Payable,
CustomerGroup? customerGroup) =>
CalculatePayableFees(
prices,
priceVersion,
priceEffectiveTime,
chargeableWeight,
new[] { zoneName },
cargoAttribute,
customerGroup,
onlyForecastChargingTime: true);
}
OrderFeeChargingStageCodes.Forecast);
public static bool HasEnabledForecastPriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
priceSnapshot.Prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime));
HasEnabledReceivablePriceItems(priceSnapshot);
public static bool HasEnabledPayablePriceItems(IReadOnlyList<ServiceChannelPriceListOutput> prices) =>
prices.Any(x => x.Status == 1);
public static bool HasEnabledForecastPayablePriceItems(IReadOnlyList<ServiceChannelPriceListOutput> prices) =>
prices.Any(x => x.Status == 1 && IsForecastChargingTime(x.ChargingTime));
public static string NormalizeChargingTime(string? chargingTime)
{
var value = chargingTime?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
return OrderFeeChargingStageCodes.Forecast;
}
return value switch
{
"预报" => OrderFeeChargingStageCodes.Forecast,
"入库" => OrderFeeChargingStageCodes.Inbound,
"出库" => OrderFeeChargingStageCodes.Outbound,
_ => value.ToLowerInvariant()
};
}
HasEnabledPayablePriceItems(prices);
public static string GetCargoAttributeLabel(string? cargoAttribute) => cargoAttribute switch
{
@ -662,25 +689,19 @@ internal static class ParcelBillingCalculator
Cubic = CalculateCubic(context.Length, context.Width, context.Height)
};
private static List<OrderFee> CalculateForecastFees(
IEnumerable<BillingPriceItem> priceItems,
string feeType,
private static List<OrderFee> CalculateReceivableFeesFromItems(
IEnumerable<ReceivableBillingPriceItem> priceItems,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
bool onlyForecastChargingTime)
string chargingStage)
{
var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
{
if (onlyForecastChargingTime && !IsForecastChargingTime(priceItem.ChargingTime))
{
continue;
}
var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight);
if (detail == null)
{
@ -696,31 +717,71 @@ internal static class ParcelBillingCalculator
customerGroup);
fees.Add(new OrderFee
{
Type = feeType,
Type = OrderFeeTypeCodes.Receivable,
CostItem = priceItem.CostItem,
Amount = amount,
Currency = priceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = OrderFeeChargingStageCodes.Forecast
ChargingStage = chargingStage
});
}
return fees;
}
private readonly record struct BillingPriceItem(
private static List<OrderFee> CalculatePayableFeesFromItems(
IEnumerable<PayableBillingPriceItem> priceItems,
string priceVersion,
DateTime priceEffectiveTime,
decimal chargeableWeight,
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage)
{
var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1))
{
var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight);
if (detail == null)
{
continue;
}
var amount = CalculateDetailAmount(
priceItem.Details,
detail,
zoneKeys,
cargoAttribute,
chargeableWeight,
customerGroup);
fees.Add(new OrderFee
{
Type = OrderFeeTypeCodes.Payable,
CostItem = priceItem.CostItem,
Amount = amount,
Currency = priceItem.Currency,
PriceNo = priceVersion,
PriceEffectiveTime = priceEffectiveTime,
ChargingStage = chargingStage
});
}
return fees;
}
private readonly record struct ReceivableBillingPriceItem(
byte Status,
string? ChargingTime,
string CostItem,
string Currency,
List<ChannelPriceDetailOutput> Details);
private static bool IsForecastChargingTime(string? chargingTime)
{
var normalized = NormalizeChargingTime(chargingTime);
return normalized is OrderFeeChargingStageCodes.Forecast or "";
}
private readonly record struct PayableBillingPriceItem(
byte Status,
string CostItem,
string Currency,
List<ChannelPriceDetailOutput> Details);
private static ChannelPriceDetailOutput? MatchPriceDetail(
IReadOnlyList<ChannelPriceDetailOutput> details,

View File

@ -19,7 +19,6 @@ public static class PublishSnapshotSlimmer
CostItem = price.CostItem,
CostUnit = price.CostUnit,
Currency = price.Currency,
ChargingTime = price.ChargingTime,
ChargingCondition = price.ChargingCondition,
Factors = price.Factors,
Details = price.Details

View File

@ -21,7 +21,5 @@ public class SalesProductPrice : OmsAuditedEntity
public string Currency { get; set; } = string.Empty;
public string? ChargingTime { get; set; }
public string? ChargingCondition { get; set; }
}

View File

@ -21,7 +21,5 @@ public class ServiceChannelPrice : OmsAuditedEntity
public string Currency { get; set; } = string.Empty;
public string? ChargingTime { get; set; }
public string? ChargingCondition { get; set; }
}

View File

@ -52,6 +52,12 @@ public interface ISalesProductRepository : IRepository<SalesProduct>
Task<SalesProductPublishRecord?> GetPublishRecordByIdAsync(int id, CancellationToken cancellationToken = default);
Task<SalesProductPublishRecord?> GetPublishRecordByVersionAsync(
int salesProductId,
string operatorType,
string version,
CancellationToken cancellationToken = default);
Task<SalesProductPublishRecord?> GetEffectivePublishRecordAsync(
int salesProductId,
string operatorType,

View File

@ -195,6 +195,28 @@ public class SalesProductRepository : OmsSqlSugarRepository<SalesProduct>, ISale
return await db.Queryable<SalesProductPublishRecord>().FirstAsync(x => x.Id == id, cancellationToken);
}
public async Task<SalesProductPublishRecord?> GetPublishRecordByVersionAsync(
int salesProductId,
string operatorType,
string version,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(version))
{
return null;
}
var db = await GetDbContextAsync(cancellationToken);
var records = await db.Queryable<SalesProductPublishRecord>()
.Where(x => x.SalesProductId == salesProductId
&& x.OperatorType == operatorType
&& x.Version == version)
.OrderBy(x => x.PublishTime, SqlSugar.OrderByType.Desc)
.Take(1)
.ToListAsync(cancellationToken);
return records.FirstOrDefault();
}
public async Task<SalesProductPublishRecord?> GetEffectivePublishRecordAsync(
int salesProductId,
string operatorType,

View File

@ -89,10 +89,10 @@ public class ParcelBillingCalculatorDemoTests
var (priceVersion, effectiveTime) = ParcelBillingTestData.SnapshotMeta();
var receivableSnapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
"预报", 1, "freight",
1, "freight",
ParcelBillingTestData.ContinueFreightDetail()),
ParcelBillingTestData.ReceivablePriceItem(
"预报", 1, "操作费",
1, "操作费",
ParcelBillingTestData.FixedPriceDetail(5M)));
var receivableFees = ParcelBillingCalculator.CalculateForecastReceivableFees(
@ -119,7 +119,7 @@ public class ParcelBillingCalculatorDemoTests
// —— 5. 应付(买价)——
var payablePrices = ParcelBillingTestData.BuildPayablePrices(
ParcelBillingTestData.PayablePriceItem(
"预报", 1, "freight",
1, "freight",
ParcelBillingTestData.ContinueFreightDetail(
firstWeight: 1M, firstPrice: 12M, continueUnit: 0.5M, continuePrice: 4M)));

View File

@ -61,7 +61,6 @@ public class ParcelBillingCalculatorTests
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
"预报",
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail()));
@ -83,30 +82,50 @@ public class ParcelBillingCalculatorTests
fees[0].PriceNo.Should().Be(_meta.PriceVersion);
}
[Theory]
[InlineData("入库")]
[InlineData("出库")]
[InlineData("inbound")]
[InlineData("outbound")]
public void CalculateForecastReceivableFees_ShouldSkipNonForecastChargingTime(string chargingTime)
[Fact]
public void CalculateReceivableFees_ShouldIncludeAllEnabledPriceItems()
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
chargingTime,
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail()));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1.2M,
Zone,
new[] { Zone },
"general",
null);
null,
OrderFeeChargingStageCodes.Forecast);
fees.Should().BeEmpty();
fees.Should().ContainSingle();
fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Forecast);
}
[Fact]
public void CalculateReceivableFees_InboundStage_ShouldSetChargingStage()
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail()));
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshot,
_meta.PriceVersion,
_meta.EffectiveTime,
1.2M,
new[] { Zone },
"general",
null,
OrderFeeChargingStageCodes.Inbound);
fees.Should().ContainSingle();
fees[0].ChargingStage.Should().Be(OrderFeeChargingStageCodes.Inbound);
}
[Fact]
@ -114,12 +133,10 @@ public class ParcelBillingCalculatorTests
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
"预报",
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail()),
ParcelBillingTestData.ReceivablePriceItem(
"预报",
1,
"操作费",
ParcelBillingTestData.FixedPriceDetail(5M)));
@ -150,7 +167,7 @@ public class ParcelBillingCalculatorTests
detail.CustomerGroupValues = new Dictionary<string, decimal> { ["客户组1"] = 15M };
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
ParcelBillingTestData.ReceivablePriceItem(1, "freight", detail));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot,
@ -170,7 +187,6 @@ public class ParcelBillingCalculatorTests
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem(
"outbound",
1,
"freight",
ParcelBillingTestData.TotalTierDetail("3", 0.5M, 10M, weightInterval: "(0-0.5]"),
@ -200,7 +216,7 @@ public class ParcelBillingCalculatorTests
}
[Fact]
public void CalculateQuoteReceivableFees_ShouldSupportOutboundTotalPriceTiers()
public void CalculateQuoteReceivableFees_ShouldSupportTotalPriceTiers()
{
var details = new[]
{
@ -210,7 +226,7 @@ public class ParcelBillingCalculatorTests
ParcelBillingTestData.TotalTierDetail("3", 2M, 14M),
};
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("outbound", 1, "freight", details));
ParcelBillingTestData.ReceivablePriceItem(1, "freight", details));
var fees = ParcelBillingCalculator.CalculateQuoteReceivableFees(
snapshot,
@ -232,7 +248,7 @@ public class ParcelBillingCalculatorTests
detail.CustomerGroupValues = new Dictionary<string, decimal> { ["普通客户"] = 12M };
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
ParcelBillingTestData.ReceivablePriceItem(1, "freight", detail));
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot,
@ -248,12 +264,13 @@ public class ParcelBillingCalculatorTests
}
[Fact]
public void HasEnabledForecastPriceItems_ShouldDetectForecastEnabledItems()
public void HasEnabledReceivablePriceItems_ShouldDetectAnyEnabledItems()
{
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("入库", 1, "freight", ParcelBillingTestData.ContinueFreightDetail()),
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", ParcelBillingTestData.ContinueFreightDetail()));
ParcelBillingTestData.ReceivablePriceItem(1, "freight", ParcelBillingTestData.ContinueFreightDetail()),
ParcelBillingTestData.ReceivablePriceItem(0, "freight", ParcelBillingTestData.ContinueFreightDetail()));
ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshot).Should().BeTrue();
ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshot).Should().BeTrue();
}
@ -271,7 +288,6 @@ public class ParcelBillingCalculatorTests
{
var prices = ParcelBillingTestData.BuildPayablePrices(
ParcelBillingTestData.PayablePriceItem(
"预报",
1,
"freight",
ParcelBillingTestData.ContinueFreightDetail(
@ -300,7 +316,6 @@ public class ParcelBillingCalculatorTests
{
var prices = ParcelBillingTestData.BuildPayablePrices(
ParcelBillingTestData.PayablePriceItem(
"预报",
0,
"freight",
ParcelBillingTestData.ContinueFreightDetail()));
@ -315,7 +330,7 @@ public class ParcelBillingCalculatorTests
null);
fees.Should().BeEmpty();
ParcelBillingCalculator.HasEnabledForecastPayablePriceItems(prices).Should().BeFalse();
ParcelBillingCalculator.HasEnabledPayablePriceItems(prices).Should().BeFalse();
}
[Fact]
@ -323,9 +338,9 @@ public class ParcelBillingCalculatorTests
{
var detail = ParcelBillingTestData.ContinueFreightDetail();
var snapshot = ParcelBillingTestData.BuildReceivableSnapshot(
ParcelBillingTestData.ReceivablePriceItem("预报", 1, "freight", detail));
ParcelBillingTestData.ReceivablePriceItem(1, "freight", detail));
var payablePrices = ParcelBillingTestData.BuildPayablePrices(
ParcelBillingTestData.PayablePriceItem("预报", 1, "freight", detail));
ParcelBillingTestData.PayablePriceItem(1, "freight", detail));
var receivable = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshot, _meta.PriceVersion, _meta.EffectiveTime, 1.2M, Zone, "general", null);

View File

@ -9,7 +9,7 @@ using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Tests;
/// <summary>
/// 验证查价卖价快照匹配:忽略收费时机、unit 公式、重量左开右闭区间。
/// 验证查价卖价快照匹配:unit 公式、重量左开右闭区间。
/// </summary>
public class PriceCheckSnapshotVerificationTests
{
@ -23,7 +23,6 @@ public class PriceCheckSnapshotVerificationTests
Id = 1,
Status = 1,
CostItem = "freight",
ChargingTime = "outbound",
Details =
[
Detail(24, 1, "total", "3", "0,0.5", 11),
@ -37,7 +36,6 @@ public class PriceCheckSnapshotVerificationTests
Id = 2,
Status = 1,
CostItem = "handling",
ChargingTime = "outbound",
Details = [Detail(28, 2, "total", "3", "0,10", 20)]
})
]

View File

@ -24,7 +24,6 @@ internal static class ParcelBillingTestData
params ServiceChannelPriceListOutput[] prices) => prices.ToList();
public static SalesProductPriceListOutput ReceivablePriceItem(
string chargingTime,
byte status,
string costItem,
params ChannelPriceDetailOutput[] details) =>
@ -33,12 +32,10 @@ internal static class ParcelBillingTestData
Status = status,
CostItem = costItem,
Currency = "CNY",
ChargingTime = chargingTime,
Details = details.ToList()
};
public static ServiceChannelPriceListOutput PayablePriceItem(
string chargingTime,
byte status,
string costItem,
params ChannelPriceDetailOutput[] details) =>
@ -47,7 +44,6 @@ internal static class ParcelBillingTestData
Status = status,
CostItem = costItem,
Currency = "CNY",
ChargingTime = chargingTime,
Details = details.ToList()
};