feat(order): 排货确认渠道与应付计费,出入库计费对齐设计

- 预报/入库后按排货规则选服务渠道并写入渠道买价应付费用

- 排货发布快照 Providers 去除 CreateTime/UpdateTime

- 出库扫描扩展 OutboundRecord 字段与 ApplyShipBillingByWaybillAsync

- 服务渠道买价发布预览;按 ChargingStage 删除应付费用

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-27 16:44:04 +08:00
parent fdbdfa8124
commit e7fd69b644
20 changed files with 844 additions and 137 deletions

11
db.sql
View File

@ -685,6 +685,13 @@ CREATE TABLE `t_inbound_record` (
CREATE TABLE `t_outbound_record` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`WaybillNo` VARCHAR(64) NOT NULL COMMENT '运单号',
`Weight` DECIMAL(10,2) NULL COMMENT '实重(kg)',
`Length` DECIMAL(10,2) NULL COMMENT '长(cm)',
`Width` DECIMAL(10,2) NULL COMMENT '宽(cm)',
`Height` DECIMAL(10,2) NULL COMMENT '高(cm)',
`SalesProductId` INT NULL COMMENT '销售产品ID',
`DestinationCountry` VARCHAR(8) NULL COMMENT '目的国',
`ServiceChannelId` INT NULL COMMENT '服务渠道ID',
`WarehouseId` INT NULL COMMENT '仓库ID',
`OperateTime` DATETIME NOT NULL COMMENT '操作时间',
`OperatorId` INT NULL COMMENT '操作人ID',
@ -696,7 +703,9 @@ CREATE TABLE `t_outbound_record` (
`UpdateTime` DATETIME NULL COMMENT '更新时间',
PRIMARY KEY (`Id`),
KEY `idx_out_waybill_time` (`WaybillNo`, `OperateTime`),
KEY `idx_out_warehouse_time` (`WarehouseId`, `OperateTime`)
KEY `idx_out_warehouse_time` (`WarehouseId`, `OperateTime`),
KEY `idx_out_product_time` (`SalesProductId`, `OperateTime`),
KEY `idx_out_channel_time` (`ServiceChannelId`, `OperateTime`)
) COMMENT='出库记录表';
CREATE TABLE `t_label_print_record` (

View File

@ -0,0 +1,13 @@
-- 出库记录表补齐件重尺、销售产品、目的国、服务渠道字段(与 t_inbound_record 对齐)
ALTER TABLE `t_outbound_record`
ADD COLUMN `Weight` DECIMAL(10,2) NULL COMMENT '实重(kg)' AFTER `WaybillNo`,
ADD COLUMN `Length` DECIMAL(10,2) NULL COMMENT '长(cm)' AFTER `Weight`,
ADD COLUMN `Width` DECIMAL(10,2) NULL COMMENT '宽(cm)' AFTER `Length`,
ADD COLUMN `Height` DECIMAL(10,2) NULL COMMENT '高(cm)' AFTER `Width`,
ADD COLUMN `SalesProductId` INT NULL COMMENT '销售产品ID' AFTER `Height`,
ADD COLUMN `DestinationCountry` VARCHAR(8) NULL COMMENT '目的国' AFTER `SalesProductId`,
ADD COLUMN `ServiceChannelId` INT NULL COMMENT '服务渠道ID' AFTER `DestinationCountry`;
ALTER TABLE `t_outbound_record`
ADD KEY `idx_out_product_time` (`SalesProductId`, `OperateTime`),
ADD KEY `idx_out_channel_time` (`ServiceChannelId`, `OperateTime`);

View File

@ -0,0 +1,47 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 排货服务商发布快照项(不含审计时间)。
/// </summary>
public class SalesProductAllocationProviderSnapshotDto
{
/// <summary>
/// 主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 销售产品主键。
/// </summary>
public int SalesProductId { get; set; }
/// <summary>
/// 序号。
/// </summary>
public int SerialNumber { get; set; }
/// <summary>
/// 供应商名称。
/// </summary>
public string ProviderName { get; set; } = string.Empty;
/// <summary>
/// 服务渠道主键。
/// </summary>
public int? ServiceChannelId { get; set; }
/// <summary>
/// 服务渠道名称。
/// </summary>
public string? ServiceChannelName { get; set; }
/// <summary>
/// 权重(评分用)。
/// </summary>
public int Weight { get; set; }
/// <summary>
/// 指定客户主键集合,空表示全部。
/// </summary>
public List<int> CustomerIds { get; set; } = [];
}

View File

@ -14,6 +14,42 @@ public class CreateOutboundRecordInput
[StringLength(64)]
public string WaybillNo { get; set; } = string.Empty;
/// <summary>
/// 重量。
/// </summary>
public decimal? Weight { get; set; }
/// <summary>
/// 长度。
/// </summary>
public decimal? Length { get; set; }
/// <summary>
/// 宽度。
/// </summary>
public decimal? Width { get; set; }
/// <summary>
/// 高度。
/// </summary>
public decimal? Height { get; set; }
/// <summary>
/// 销售产品主键。
/// </summary>
public int? SalesProductId { get; set; }
/// <summary>
/// 目的国家/地区。
/// </summary>
[StringLength(8)]
public string? DestinationCountry { get; set; }
/// <summary>
/// 服务渠道主键。
/// </summary>
public int? ServiceChannelId { get; set; }
/// <summary>
/// 仓库主键。
/// </summary>

View File

@ -15,6 +15,51 @@ public class OutboundRecordOutput
/// </summary>
public string WaybillNo { get; set; } = string.Empty;
/// <summary>
/// 重量。
/// </summary>
public decimal? Weight { get; set; }
/// <summary>
/// 长度。
/// </summary>
public decimal? Length { get; set; }
/// <summary>
/// 宽度。
/// </summary>
public decimal? Width { get; set; }
/// <summary>
/// 高度。
/// </summary>
public decimal? Height { get; set; }
/// <summary>
/// 销售产品主键。
/// </summary>
public int? SalesProductId { get; set; }
/// <summary>
/// 销售产品名称。
/// </summary>
public string? SalesProductName { get; set; }
/// <summary>
/// 目的国家/地区。
/// </summary>
public string? DestinationCountry { get; set; }
/// <summary>
/// 服务渠道主键。
/// </summary>
public int? ServiceChannelId { get; set; }
/// <summary>
/// 服务渠道名称。
/// </summary>
public string? ServiceChannelName { get; set; }
/// <summary>
/// 仓库主键。
/// </summary>

View File

@ -44,9 +44,9 @@ public class SalesProductRulesPublishSnapshotDto
public SalesProductSaveInput? Product { get; set; }
/// <summary>
/// 分配的服务商列表。
/// 分配的服务商列表(发布快照,不含创建/更新时间)。
/// </summary>
public List<SalesProductAllocationProviderOutput> Providers { get; set; } = [];
public List<SalesProductAllocationProviderSnapshotDto> Providers { get; set; } = [];
/// <summary>
/// 黑名单分区 ID(发布时固化)。
@ -109,6 +109,16 @@ public class SalesProductRulesPublishSnapshotDto
public List<WeightRoundingSnapshotRangeDto> WeightRoundingInfos { get; set; } = [];
}
/// <summary>
/// 服务渠道买价发布快照。
/// </summary>
public class ServiceChannelPricePublishSnapshotDto
{
public int ServiceChannelId { get; set; }
public List<ServiceChannelPriceListOutput> Prices { get; set; } = [];
}
/// <summary>
/// 绑定到订单的快照版本信息。
/// </summary>

View File

@ -166,6 +166,8 @@ public partial class SalesProductService
{
Product = MapProductToRulesSaveInput(product),
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
.Select(PublishSnapshotSlimmer.SlimAllocationProvider)
.ToList()
};
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
rulesSnapshot,

View File

@ -173,35 +173,56 @@ public partial class ServiceChannelService
};
}
/// <summary>
/// 组装买价发布快照(不落库),与产品卖价 §5.3.2 对称。
/// </summary>
private async Task<ServiceChannelPricePublishSnapshot> BuildPricePublishSnapshotAsync(int serviceChannelId)
{
return new ServiceChannelPricePublishSnapshot
{
ServiceChannelId = serviceChannelId,
Prices = (await GetPriceOutputsAsync(serviceChannelId))
.Select(PublishSnapshotSlimmer.SlimChannelPrice)
.ToList()
};
}
/// <summary>
/// 组装渠道规则发布快照(不落库)。
/// </summary>
private async Task<ServiceChannelRulesPublishSnapshot> BuildRulesPublishSnapshotAsync(int serviceChannelId)
{
var entity = await GetEntityOrThrowAsync(serviceChannelId);
return new ServiceChannelRulesPublishSnapshot
{
Channel = MapChannelToSaveInput(entity)
};
}
private static ServiceChannelSaveInput MapChannelToSaveInput(ServiceChannel entity) =>
new()
{
Id = entity.Id,
GroupId = entity.GroupId,
ChannelCode = entity.ChannelCode,
ChannelName = entity.ChannelName,
MinTime = entity.MinTime,
MaxTime = entity.MaxTime,
ForecastType = entity.ForecastType,
ForecastCode = entity.ForecastCode,
Description = entity.Description,
PreScript = entity.PreScript,
IsActive = entity.IsActive
};
/// <summary>
/// 为当前配置生成发布快照。
/// </summary>
private async Task CreatePublishRecordAsync(int serviceChannelId, string operatorType, string? description)
{
var entity = await GetEntityOrThrowAsync(serviceChannelId);
object snapshot = operatorType == PriceOperatorType
? new ServiceChannelPricePublishSnapshot
{
ServiceChannelId = serviceChannelId,
Prices = await GetPriceOutputsAsync(serviceChannelId)
}
: new ServiceChannelRulesPublishSnapshot
{
Channel = new ServiceChannelSaveInput
{
Id = entity.Id,
GroupId = entity.GroupId,
ChannelCode = entity.ChannelCode,
ChannelName = entity.ChannelName,
MinTime = entity.MinTime,
MaxTime = entity.MaxTime,
ForecastType = entity.ForecastType,
ForecastCode = entity.ForecastCode,
Description = entity.Description,
PreScript = entity.PreScript,
IsActive = entity.IsActive
}
};
? await BuildPricePublishSnapshotAsync(serviceChannelId)
: await BuildRulesPublishSnapshotAsync(serviceChannelId);
// Content 中保存完整快照,回滚时直接从快照恢复。
await ServiceChannelRepository.InsertPublishRecordAsync(new ServiceChannelPublishRecord

View File

@ -423,6 +423,18 @@ public partial class ServiceChannelService : ApplicationService
return GetPublishListAsync(input, PriceOperatorType);
}
/// <summary>
/// 预览即将发布的快照 JSON(不落库),与产品卖价 publish/preview 对称。
/// </summary>
[HttpGet("/channel/service-channel/publish/preview")]
public async Task<object> PreviewPublishSnapshotAsync([FromQuery] int serviceChannelId, [FromQuery] string operatorType)
{
var normalized = NormalizeOperatorType(operatorType);
return normalized == PriceOperatorType
? await BuildPricePublishSnapshotAsync(serviceChannelId)
: await BuildRulesPublishSnapshotAsync(serviceChannelId);
}
/// <summary>
/// 查询服务渠道发布规则列表。
/// </summary>

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.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OperationsAggregate.Entities;
@ -25,6 +26,7 @@ public class OutboundRecordService : ApplicationService
private IServiceChannelGroupRepository ServiceChannelGroupRepository => LazyServiceProvider.GetRequiredService<IServiceChannelGroupRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
/// <summary>
/// 按单号查询订单(出库扫描展示销售产品、目的国、服务渠道,只读)。
@ -92,12 +94,6 @@ public class OutboundRecordService : ApplicationService
/// </summary>
/// <param name="input">入参:出库记录创建参数。</param>
/// <returns>出库记录结果。</returns>
/// <remarks>
/// 核心业务逻辑:
/// 1. 校验入参与关联对象、唯一性等前置条件。
/// 2. 组装目标实体及相关明细数据。
/// 3. 写入审计字段并持久化结果。
/// </remarks>
[HttpPost("/operation/outbound/create")]
public async Task<OutboundRecordOutput> CreateAsync(CreateOutboundRecordInput input)
{
@ -106,6 +102,12 @@ public class OutboundRecordService : 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");
}
var order = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim());
if (order == null)
{
@ -114,16 +116,45 @@ public class OutboundRecordService : ApplicationService
EnsureOutboundAllowed(order.OrderStatus);
int? salesProductId = input.SalesProductId;
if (!salesProductId.HasValue && !string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
salesProductId = product?.Id;
}
var destinationCountry = input.DestinationCountry ?? order.ConsigneeCountry;
var serviceChannelId = input.ServiceChannelId;
if (!serviceChannelId.HasValue && !string.IsNullOrWhiteSpace(order.ChannelCode))
{
var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim());
serviceChannelId = channel?.Id;
}
var now = DateTime.Now;
var operatorId = CurrentUser.GetId<int>();
var entity = new OutboundRecord
{
WaybillNo = input.WaybillNo.Trim(),
Weight = input.Weight,
Length = input.Length,
Width = input.Width,
Height = input.Height,
SalesProductId = salesProductId,
DestinationCountry = destinationCountry,
ServiceChannelId = serviceChannelId,
WarehouseId = input.WarehouseId ?? order.WarehouseId,
OperateTime = now,
OperatorId = operatorId
};
await OrderService.ApplyShipBillingByWaybillAsync(
input.WaybillNo.Trim(),
input.Length,
input.Width,
input.Height,
input.Weight);
var savedEntity = await OutboundRecordRepository.InsertAsync(entity);
return ObjectMapper.Map<OutboundRecord, OutboundRecordOutput>(savedEntity);
}
@ -134,12 +165,6 @@ public class OutboundRecordService : ApplicationService
/// <param name="limit">入参:返回记录条数上限。</param>
/// <param name="warehouseId">入参:warehouseId。</param>
/// <returns>出库记录列表结果。</returns>
/// <remarks>
/// 核心业务逻辑:
/// 1. 根据查询条件构造筛选规则。
/// 2. 查询目标数据并补齐关联展示字段。
/// 3. 按接口约定返回列表结果。
/// </remarks>
[HttpGet("/operation/outbound/recent-list")]
public async Task<OmsItemsResult<OutboundRecordOutput>> GetRecentListAsync([FromQuery] int limit = 20, [FromQuery] int? warehouseId = null)
{
@ -151,21 +176,52 @@ public class OutboundRecordService : ApplicationService
limit <= 0 ? 20 : limit,
new Dictionary<string, bool> { [nameof(OutboundRecord.OperateTime)] = false, [nameof(OutboundRecord.Id)] = false });
var salesProductIds = entities.Where(x => x.SalesProductId.HasValue).Select(x => x.SalesProductId!.Value).Distinct().ToArray();
var serviceChannelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
var warehouseIds = entities.Where(x => x.WarehouseId.HasValue).Select(x => x.WarehouseId!.Value).Distinct().ToArray();
var products = salesProductIds.Length == 0
? []
: await SalesProductRepository.GetListAsync(x => salesProductIds.Contains(x.Id));
var channels = serviceChannelIds.Length == 0
? []
: await ServiceChannelRepository.GetListAsync(x => serviceChannelIds.Contains(x.Id));
var warehouses = warehouseIds.Length == 0
? []
: await WarehouseAddressRepository.GetListAsync(x => warehouseIds.Contains(x.Id));
var productMap = products.ToDictionary(x => x.Id);
var channelMap = channels.ToDictionary(x => x.Id);
var warehouseMap = warehouses.ToDictionary(x => x.Id);
var groupIds = channels.Where(x => x.GroupId.HasValue).Select(x => x.GroupId!.Value).Distinct().ToArray();
var groups = groupIds.Length == 0
? []
: await ServiceChannelGroupRepository.GetListAsync(x => groupIds.Contains(x.Id));
var groupMap = groups.ToDictionary(x => x.Id);
var items = entities.Select(entity =>
var items = new List<OutboundRecordOutput>();
foreach (var entity in entities)
{
var output = ObjectMapper.Map<OutboundRecord, OutboundRecordOutput>(entity);
output.SalesProductName = entity.SalesProductId.HasValue && productMap.TryGetValue(entity.SalesProductId.Value, out var product)
? product.ProductName
: null;
output.WarehouseName = entity.WarehouseId.HasValue && warehouseMap.TryGetValue(entity.WarehouseId.Value, out var warehouse)
? warehouse.WarehouseName
: null;
if (entity.ServiceChannelId.HasValue && channelMap.TryGetValue(entity.ServiceChannelId.Value, out var channel))
{
string? groupName = null;
if (channel.GroupId.HasValue && groupMap.TryGetValue(channel.GroupId.Value, out var group))
{
groupName = group.Name;
}
var displayName = $"{groupName}{channel.ChannelName}".Trim();
output.ServiceChannelName = string.IsNullOrWhiteSpace(displayName) ? channel.ChannelName : displayName;
}
output.OperatorName = entity.OperatorId?.ToString();
return output;
}).ToList();
items.Add(output);
}
return new OmsItemsResult<OutboundRecordOutput> { Items = items };
}
@ -179,6 +235,12 @@ public class OutboundRecordService : ApplicationService
if (orderStatus != OrderStatusCodes.PendingShip)
{
if (orderStatus is OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed)
{
throw new BusinessException("订单已出库");
}
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(orderStatus)}」,不允许出库");
}
}

View File

@ -203,22 +203,6 @@ public partial class OrderService
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
SyncWeightRows(weights, forecast);
}
else if (updatedTypes.Contains(OrderWeightTypeCodes.Receive))
{
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
if (receive != null)
{
SyncReceiveToShipAndDownstream(weights, receive);
}
}
else if (updatedTypes.Contains(OrderWeightTypeCodes.Ship))
{
var ship = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Ship);
if (ship != null)
{
SyncShipToDownstream(weights, ship);
}
}
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
order.UpdateTime = DateTime.Now;
@ -307,28 +291,9 @@ public partial class OrderService
private async Task RecalculatePayableCoreAsync(Order order)
{
if (string.IsNullOrWhiteSpace(order.ChannelCode))
{
throw new BusinessException("订单未绑定服务渠道,无法重算应付");
}
var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim());
if (channel == null)
{
throw new BusinessException("服务渠道不存在");
}
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
var downstream = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream)
?? throw new BusinessException("缺少 downstream 件重尺");
if (!downstream.ChargeableWeight.HasValue || downstream.ChargeableWeight <= 0)
{
throw new BusinessException("downstream 计费重无效,请先完善件重尺");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法匹配分区");
throw new BusinessException("订单未绑定销售产品,无法重算应付");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
@ -337,6 +302,13 @@ public partial class OrderService
throw new BusinessException("销售产品不存在");
}
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
var weightRow = ResolvePayableWeightRow(order, weights);
if (!weightRow.ChargeableWeight.HasValue || weightRow.ChargeableWeight <= 0)
{
throw new BusinessException("计费重无效,请先完善件重尺");
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
@ -353,37 +325,38 @@ public partial class OrderService
throw new BusinessException("价格分区未匹配(规则快照)");
}
var channelPrices = await BuildChannelPriceOutputsAsync(channel.Id);
var (_, chargingStage) = ResolveReceivableBillingStage(order);
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
var fees = ParcelBillingCalculator.CalculatePayableFees(
channelPrices,
$"LIVE-{channel.Id}",
order.CreateTime == default ? DateTime.Now : order.CreateTime,
downstream.ChargeableWeight ?? 0,
await ConfirmServiceChannelAndPayableAsync(
order,
product,
snapshotBinding,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
chargingStage);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledPayablePriceItems(channelPrices))
{
throw new BusinessException("渠道买价未配置或未匹配到有效价格");
}
if (fees.Count > 0)
{
await OrderRepository.DeletePayableFeesAsync(order.Id);
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
weightRow.ChargeableWeight ?? 0,
chargingStage,
customer?.CustomerGroup);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private static OrderWeight ResolvePayableWeightRow(Order order, IReadOnlyList<OrderWeight> weights)
{
var (_, chargingStage) = ResolveReceivableBillingStage(order);
var weightType = chargingStage switch
{
OrderFeeChargingStageCodes.Inbound => OrderWeightTypeCodes.Receive,
_ => OrderWeightTypeCodes.Downstream
};
return weights.FirstOrDefault(x => x.Type == weightType)
?? weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream)
?? throw new BusinessException($"缺少件重尺类型 {weightType}");
}
private async Task<List<ServiceChannelPriceListOutput>> BuildChannelPriceOutputsAsync(int serviceChannelId)
{
var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId);

View File

@ -2,6 +2,7 @@ using Fake.Application;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
@ -111,6 +112,16 @@ public partial class OrderService
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Forecast);
var downstream = weights.First(x => x.Type == OrderWeightTypeCodes.Downstream);
await ConfirmServiceChannelAndPayableAsync(
order,
product,
snapshotBinding,
zoneKeys,
downstream.ChargeableWeight ?? forecast.ChargeableWeight ?? 0,
OrderFeeChargingStageCodes.Forecast,
customer?.CustomerGroup);
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.IsRemote = order.IsRemote;
@ -388,13 +399,166 @@ public partial class OrderService
{
if (replaceExistingReceivable)
{
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
await OrderRepository.DeleteReceivableFeesByStageAsync(order.Id, chargingStage);
}
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
}
/// <summary>
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
/// </summary>
private async Task ConfirmServiceChannelAndPayableAsync(
Order order,
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
IReadOnlyList<string> zoneKeys,
decimal chargeableWeight,
string chargingStage,
CustomerGroup? customerGroup)
{
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId);
if (filtered.Count == 0)
{
throw new BusinessException("排货规则未匹配到可用服务渠道");
}
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
var scheme = snapshotBinding.Rules.Product?.Scheme
?? product.Scheme
?? AllocationSchemeCodes.CostLowest;
var candidates = new List<AllocationResolver.ChannelCandidate>();
foreach (var provider in filtered)
{
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId!.Value);
if (channel == null || channel.IsActive != EnableStatus.Enabled)
{
continue;
}
var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime);
var previewFees = ParcelBillingCalculator.CalculatePayableFees(
channelPriceBinding.Prices,
channelPriceBinding.PriceVersion,
channelPriceBinding.PriceEffectiveTime,
chargeableWeight,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customerGroup,
chargingStage);
if (previewFees.Count == 0)
{
continue;
}
candidates.Add(new AllocationResolver.ChannelCandidate
{
Provider = provider,
Channel = channel,
TotalPayableCost = previewFees.Sum(x => x.Amount)
});
}
if (candidates.Count == 0)
{
throw new BusinessException("排货规则未匹配到有效渠道买价");
}
var selectedChannel = AllocationResolver.SelectChannel(scheme, candidates);
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))
{
throw new BusinessException("渠道买价未配置或未匹配到有效价格");
}
order.ChannelCode = selectedChannel.ChannelCode;
if (payableFees.Count > 0)
{
await OrderRepository.DeletePayableFeesByStageAsync(order.Id, chargingStage);
await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
}
}
private async Task<List<SalesProductAllocationProviderSnapshotDto>> ResolveAllocationProvidersAsync(
SalesProduct product,
SalesProductRulesPublishSnapshotDto rulesSnapshot)
{
if (rulesSnapshot.Providers.Count > 0)
{
return rulesSnapshot.Providers;
}
var entities = await SalesProductRepository.GetAllocationProvidersAsync(product.Id);
var channelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
var channels = channelIds.Length == 0
? []
: await ServiceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
var channelMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
return entities.Select(provider => PublishSnapshotSlimmer.SlimAllocationProvider(new SalesProductAllocationProviderOutput
{
Id = provider.Id,
SalesProductId = provider.SalesProductId,
SerialNumber = provider.SerialNumber,
ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
Weight = provider.Weight,
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? []
})).ToList();
}
private sealed class ChannelPriceBinding
{
public List<ServiceChannelPriceListOutput> Prices { get; init; } = [];
public string PriceVersion { get; init; } = string.Empty;
public DateTime PriceEffectiveTime { get; init; }
}
private async Task<ChannelPriceBinding> ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime)
{
var priceRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync(
serviceChannelId,
PriceOperatorType,
bindTime);
if (priceRecord?.Content != null)
{
var snapshot = OmsDocHelper.DeserializeJson<ServiceChannelPricePublishSnapshotDto>(priceRecord.Content);
if (snapshot?.Prices.Count > 0)
{
return new ChannelPriceBinding
{
Prices = snapshot.Prices,
PriceVersion = priceRecord.Version,
PriceEffectiveTime = priceRecord.PublishTime
};
}
}
return new ChannelPriceBinding
{
Prices = await BuildChannelPriceOutputsAsync(serviceChannelId),
PriceVersion = $"LIVE-{serviceChannelId}",
PriceEffectiveTime = bindTime
};
}
private async Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
{
var snapshot = new SalesProductRulesPublishSnapshotDto
@ -419,6 +583,7 @@ public partial class OrderService
KpiAreaRepository,
WeightRuleRepository,
WeightRoundingRepository);
snapshot.Providers = await ResolveAllocationProvidersAsync(product, snapshot);
return snapshot;
}
@ -843,43 +1008,6 @@ 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 void SyncShipToDownstream(List<OrderWeight> weights, OrderWeight ship)
{
var row = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream);
if (row == null)
{
return;
}
row.Length = ship.Length;
row.Width = ship.Width;
row.Height = ship.Height;
row.ActualWeight = ship.ActualWeight;
row.VolumetricWeight = ship.VolumetricWeight;
row.ChargeableWeight = ship.ChargeableWeight;
row.Cubic = ship.Cubic;
}
private static Order BuildDraftOrderEntity(
OrderParcelCreateInput input,
SalesProduct product,
@ -1051,7 +1179,7 @@ public partial class OrderService
}
/// <summary>
/// 入库收货后:覆盖 receive 件重尺、重算计费重,覆盖写入 inbound 阶段应收,并同步 ship/downstream 重量行。
/// 入库收货后:仅覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并更新订单状态。
/// </summary>
internal async Task ApplyReceiveBillingByWaybillAsync(
string waybillNo,
@ -1110,7 +1238,6 @@ public partial class OrderService
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
SyncReceiveToShipAndDownstream(weights, receive);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
@ -1142,12 +1269,130 @@ public partial class OrderService
OrderFeeChargingStageCodes.Inbound,
replaceExistingReceivable: true);
await ConfirmServiceChannelAndPayableAsync(
order,
product,
snapshotBinding,
zoneKeys,
receive.ChargeableWeight ?? 0,
OrderFeeChargingStageCodes.Inbound,
customer?.CustomerGroup);
order.OrderStatus = OrderStatusCodes.PendingPayment;
order.ReceiveTime = DateTime.Now;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
/// <summary>
/// 出库发货后:仅覆盖 ship 件重尺、重算计费重,写入 outbound 阶段应收,并更新订单状态。
/// </summary>
internal async Task ApplyShipBillingByWaybillAsync(
string waybillNo,
decimal? length,
decimal? width,
decimal? height,
decimal? actualWeight)
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
throw new BusinessException("运单号不能为空");
}
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
throw new BusinessException("发货实重与长宽高不能为空且须大于 0");
}
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法出库计费");
}
if (order.OrderStatus is OrderStatusCodes.PendingPayment or OrderStatusCodes.Received)
{
throw new BusinessException("订单未付款,禁止出库");
}
if (order.OrderStatus != OrderStatusCodes.PendingShip)
{
if (order.OrderStatus is OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed)
{
throw new BusinessException("订单已出库");
}
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许出库");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("订单关联的销售产品不存在");
}
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
var ship = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Ship);
if (ship == null)
{
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);
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(
order,
snapshotBinding,
ship.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Outbound,
replaceExistingReceivable: true);
var now = DateTime.Now;
order.OrderStatus = OrderStatusCodes.PendingOnline;
order.ShipTime = now;
order.UpdateTime = now;
await OrderRepository.UpdateAsync(order);
}
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
{
var mapped = (await MapOrderOutputsAsync([order])).First();

View File

@ -0,0 +1,61 @@
using Oms.Application.Dtos.Channels;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Application.Shared;
/// <summary>
/// 排货规则运行时选渠。
/// </summary>
public static class AllocationResolver
{
public sealed class ChannelCandidate
{
public SalesProductAllocationProviderSnapshotDto Provider { get; init; } = null!;
public ServiceChannel Channel { get; init; } = null!;
public decimal TotalPayableCost { get; init; }
}
public static List<SalesProductAllocationProviderSnapshotDto> FilterProviders(
IEnumerable<SalesProductAllocationProviderSnapshotDto> providers,
int? customerId) =>
providers
.Where(x => x.ServiceChannelId.HasValue && x.ServiceChannelId > 0)
.Where(x => x.CustomerIds.Count == 0
|| (customerId.HasValue && x.CustomerIds.Contains(customerId.Value)))
.OrderBy(x => x.SerialNumber)
.ToList();
public static ServiceChannel SelectChannel(string? scheme, IReadOnlyList<ChannelCandidate> candidates)
{
if (candidates.Count == 0)
{
throw new InvalidOperationException("无可用排货候选渠道");
}
var normalizedScheme = string.IsNullOrWhiteSpace(scheme)
? AllocationSchemeCodes.CostLowest
: scheme.Trim();
return normalizedScheme switch
{
AllocationSchemeCodes.TimeFastest => candidates
.OrderBy(x => x.Channel.MinTime ?? int.MaxValue)
.ThenBy(x => x.Provider.SerialNumber)
.First()
.Channel,
AllocationSchemeCodes.OverallBest => candidates
.OrderByDescending(x => x.Provider.Weight)
.ThenBy(x => x.Provider.SerialNumber)
.First()
.Channel,
_ => candidates
.OrderBy(x => x.TotalPayableCost)
.ThenBy(x => x.Provider.SerialNumber)
.First()
.Channel
};
}
}

View File

@ -24,6 +24,36 @@ public static class PublishSnapshotSlimmer
Details = price.Details
};
public static ServiceChannelPriceListOutput SlimChannelPrice(ServiceChannelPriceListOutput price) =>
new()
{
Id = price.Id,
ServiceChannelId = price.ServiceChannelId,
SerialNumber = price.SerialNumber,
Status = price.Status,
PriceName = price.PriceName,
CostItem = price.CostItem,
CostUnit = price.CostUnit,
Currency = price.Currency,
ChargingCondition = price.ChargingCondition,
Factors = price.Factors,
Details = price.Details
};
public static SalesProductAllocationProviderSnapshotDto SlimAllocationProvider(
SalesProductAllocationProviderOutput provider) =>
new()
{
Id = provider.Id,
SalesProductId = provider.SalesProductId,
SerialNumber = provider.SerialNumber,
ProviderName = provider.ProviderName,
ServiceChannelId = provider.ServiceChannelId,
ServiceChannelName = provider.ServiceChannelName,
Weight = provider.Weight,
CustomerIds = provider.CustomerIds
};
public static WeightRuleSnapshotRuleDto SlimWeightRule(WeightRuleInfo entity) =>
new()
{

View File

@ -49,4 +49,10 @@ public interface IServiceChannelRepository : IRepository<ServiceChannel>
Task<ServiceChannelPublishRecord> InsertPublishRecordAsync(ServiceChannelPublishRecord record, CancellationToken cancellationToken = default);
Task<ServiceChannelPublishRecord?> GetPublishRecordByIdAsync(int id, CancellationToken cancellationToken = default);
Task<ServiceChannelPublishRecord?> GetEffectivePublishRecordAsync(
int serviceChannelId,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default);
}

View File

@ -8,6 +8,20 @@ public class OutboundRecord : OmsAuditedAggregateRoot
{
public string WaybillNo { get; set; } = string.Empty;
public decimal? Weight { get; set; }
public decimal? Length { get; set; }
public decimal? Width { get; set; }
public decimal? Height { get; set; }
public int? SalesProductId { get; set; }
public string? DestinationCountry { get; set; }
public int? ServiceChannelId { get; set; }
public int? WarehouseId { get; set; }
public DateTime OperateTime { get; set; }

View File

@ -41,8 +41,12 @@ public interface IOrderRepository : IRepository<Order>
Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default);
Task DeleteReceivableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default);
Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default);
Task DeletePayableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default);
Task UpdateSenderAsync(OrderSender sender, CancellationToken cancellationToken = default);
Task UpdateRecipientAsync(OrderRecipient recipient, CancellationToken cancellationToken = default);

View File

@ -179,6 +179,24 @@ public class ServiceChannelRepository : OmsSqlSugarRepository<ServiceChannel>, I
return await db.Queryable<ServiceChannelPublishRecord>().FirstAsync(x => x.Id == id, cancellationToken);
}
public async Task<ServiceChannelPublishRecord?> GetEffectivePublishRecordAsync(
int serviceChannelId,
string operatorType,
DateTime bindTime,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var records = await db.Queryable<ServiceChannelPublishRecord>()
.Where(x => x.ServiceChannelId == serviceChannelId
&& x.OperatorType == operatorType
&& x.Status == "生效"
&& x.PublishTime <= bindTime)
.OrderBy(x => x.PublishTime, SqlSugar.OrderByType.Desc)
.Take(1)
.ToListAsync(cancellationToken);
return records.FirstOrDefault();
}
private static async Task InsertPriceChildrenAsync(
SqlSugar.ISqlSugarClient db,
int priceId,

View File

@ -216,6 +216,16 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeleteReceivableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Receivable
&& x.ChargingStage == chargingStage)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
@ -224,6 +234,16 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeletePayableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId
&& x.Type == OrderFeeTypeCodes.Payable
&& x.ChargingStage == chargingStage)
.ExecuteCommandAsync(cancellationToken);
}
public async Task UpdateSenderAsync(OrderSender sender, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);

View File

@ -0,0 +1,79 @@
using FluentAssertions;
using Oms.Application.Dtos.Channels;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Tests;
public class AllocationResolverTests
{
[Fact]
public void FilterProviders_ShouldRespectCustomerScope()
{
var providers = new List<SalesProductAllocationProviderSnapshotDto>
{
new() { SerialNumber = 1, ServiceChannelId = 1, CustomerIds = [10] },
new() { SerialNumber = 2, ServiceChannelId = 2, CustomerIds = [] },
new() { SerialNumber = 3, ServiceChannelId = null }
};
var filtered = AllocationResolver.FilterProviders(providers, 10);
filtered.Should().HaveCount(2);
filtered.Select(x => x.ServiceChannelId).Should().BeEquivalentTo([1, 2]);
}
[Fact]
public void SelectChannel_CostLowest_ShouldPickCheapest()
{
var candidates = BuildCandidates(
(1, 30m, 5, 10),
(2, 20m, 8, 20));
var channel = AllocationResolver.SelectChannel(AllocationSchemeCodes.CostLowest, candidates);
channel.Id.Should().Be(2);
}
[Fact]
public void SelectChannel_TimeFastest_ShouldPickLowestMinTime()
{
var candidates = BuildCandidates(
(1, 30m, 5, 10),
(2, 20m, 3, 20));
var channel = AllocationResolver.SelectChannel(AllocationSchemeCodes.TimeFastest, candidates);
channel.Id.Should().Be(2);
}
[Fact]
public void SelectChannel_OverallBest_ShouldPickHighestWeight()
{
var candidates = BuildCandidates(
(1, 30m, 5, 10),
(2, 20m, 8, 30));
var channel = AllocationResolver.SelectChannel(AllocationSchemeCodes.OverallBest, candidates);
channel.Id.Should().Be(2);
}
private static List<AllocationResolver.ChannelCandidate> BuildCandidates(
params (int channelId, decimal cost, int minTime, int weight)[] rows)
{
return rows.Select(row => new AllocationResolver.ChannelCandidate
{
Provider = new SalesProductAllocationProviderSnapshotDto
{
SerialNumber = row.channelId,
ServiceChannelId = row.channelId,
Weight = row.weight
},
Channel = new ServiceChannel
{
Id = row.channelId,
ChannelCode = $"CH-{row.channelId}",
MinTime = row.minTime
},
TotalPayableCost = row.cost
}).ToList();
}
}