feat(order): 排货确认渠道与应付计费,出入库计费对齐设计
- 预报/入库后按排货规则选服务渠道并写入渠道买价应付费用 - 排货发布快照 Providers 去除 CreateTime/UpdateTime - 出库扫描扩展 OutboundRecord 字段与 ApplyShipBillingByWaybillAsync - 服务渠道买价发布预览;按 ChargingStage 删除应付费用 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fdbdfa8124
commit
e7fd69b644
11
db.sql
11
db.sql
@ -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` (
|
||||
|
||||
13
sql/migrate_t_outbound_record_columns.sql
Normal file
13
sql/migrate_t_outbound_record_columns.sql
Normal 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`);
|
||||
@ -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; } = [];
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -166,6 +166,8 @@ public partial class SalesProductService
|
||||
{
|
||||
Product = MapProductToRulesSaveInput(product),
|
||||
Providers = (await GetAllocationCoreAsync(salesProductId)).Providers
|
||||
.Select(PublishSnapshotSlimmer.SlimAllocationProvider)
|
||||
.ToList()
|
||||
};
|
||||
await RulesSnapshotHelper.ApplyRulesToSnapshotAsync(
|
||||
rulesSnapshot,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}」,不允许出库");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
61
src/Oms.Application/Shared/AllocationResolver.cs
Normal file
61
src/Oms.Application/Shared/AllocationResolver.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
79
src/Oms.Tests/AllocationResolverTests.cs
Normal file
79
src/Oms.Tests/AllocationResolverTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user