From e7fd69b644f5391531b2869dfd8b27aa69618850 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 27 Jun 2026 16:44:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(order):=20=E6=8E=92=E8=B4=A7=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=B8=A0=E9=81=93=E4=B8=8E=E5=BA=94=E4=BB=98=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=EF=BC=8C=E5=87=BA=E5=85=A5=E5=BA=93=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预报/入库后按排货规则选服务渠道并写入渠道买价应付费用 - 排货发布快照 Providers 去除 CreateTime/UpdateTime - 出库扫描扩展 OutboundRecord 字段与 ApplyShipBillingByWaybillAsync - 服务渠道买价发布预览;按 ChargingStage 删除应付费用 Co-authored-by: Cursor --- db.sql | 11 +- sql/migrate_t_outbound_record_columns.sql | 13 + ...lesProductAllocationProviderSnapshotDto.cs | 47 +++ .../Operations/CreateOutboundRecordInput.cs | 36 ++ .../Dtos/Operations/OutboundRecordOutput.cs | 45 +++ .../Dtos/Orders/ProductBillingSnapshotDtos.cs | 14 +- .../Channels/SalesProductService.Helpers.cs | 2 + .../Channels/ServiceChannelService.Helpers.cs | 67 ++-- .../Channels/ServiceChannelService.cs | 12 + .../Operations/OutboundRecordService.cs | 92 ++++- .../Services/Orders/OrderService.OrderEdit.cs | 85 ++--- .../Orders/OrderService.ParcelBilling.cs | 325 +++++++++++++++--- .../Shared/AllocationResolver.cs | 61 ++++ .../Shared/PublishSnapshotSlimmer.cs | 30 ++ .../Repositories/IServiceChannelRepository.cs | 6 + .../Entities/OutboundRecord.cs | 14 + .../Repositories/IOrderRepository.cs | 4 + .../Channels/ServiceChannelRepository.cs | 18 + .../Orders/OrderRepository.cs | 20 ++ src/Oms.Tests/AllocationResolverTests.cs | 79 +++++ 20 files changed, 844 insertions(+), 137 deletions(-) create mode 100644 sql/migrate_t_outbound_record_columns.sql create mode 100644 src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs create mode 100644 src/Oms.Application/Shared/AllocationResolver.cs create mode 100644 src/Oms.Tests/AllocationResolverTests.cs diff --git a/db.sql b/db.sql index 1a2694e..e02fc29 100644 --- a/db.sql +++ b/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` ( diff --git a/sql/migrate_t_outbound_record_columns.sql b/sql/migrate_t_outbound_record_columns.sql new file mode 100644 index 0000000..5fd492f --- /dev/null +++ b/sql/migrate_t_outbound_record_columns.sql @@ -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`); diff --git a/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs new file mode 100644 index 0000000..b513545 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/SalesProductAllocationProviderSnapshotDto.cs @@ -0,0 +1,47 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 排货服务商发布快照项(不含审计时间)。 +/// +public class SalesProductAllocationProviderSnapshotDto +{ + /// + /// 主键。 + /// + public int Id { get; set; } + + /// + /// 销售产品主键。 + /// + public int SalesProductId { get; set; } + + /// + /// 序号。 + /// + public int SerialNumber { get; set; } + + /// + /// 供应商名称。 + /// + public string ProviderName { get; set; } = string.Empty; + + /// + /// 服务渠道主键。 + /// + public int? ServiceChannelId { get; set; } + + /// + /// 服务渠道名称。 + /// + public string? ServiceChannelName { get; set; } + + /// + /// 权重(评分用)。 + /// + public int Weight { get; set; } + + /// + /// 指定客户主键集合,空表示全部。 + /// + public List CustomerIds { get; set; } = []; +} diff --git a/src/Oms.Application/Dtos/Operations/CreateOutboundRecordInput.cs b/src/Oms.Application/Dtos/Operations/CreateOutboundRecordInput.cs index 4b11c74..05ddc61 100644 --- a/src/Oms.Application/Dtos/Operations/CreateOutboundRecordInput.cs +++ b/src/Oms.Application/Dtos/Operations/CreateOutboundRecordInput.cs @@ -14,6 +14,42 @@ public class CreateOutboundRecordInput [StringLength(64)] 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; } + + /// + /// 目的国家/地区。 + /// + [StringLength(8)] + public string? DestinationCountry { get; set; } + + /// + /// 服务渠道主键。 + /// + public int? ServiceChannelId { get; set; } + /// /// 仓库主键。 /// diff --git a/src/Oms.Application/Dtos/Operations/OutboundRecordOutput.cs b/src/Oms.Application/Dtos/Operations/OutboundRecordOutput.cs index 67d66b9..350f985 100644 --- a/src/Oms.Application/Dtos/Operations/OutboundRecordOutput.cs +++ b/src/Oms.Application/Dtos/Operations/OutboundRecordOutput.cs @@ -15,6 +15,51 @@ public class OutboundRecordOutput /// 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? SalesProductName { get; set; } + + /// + /// 目的国家/地区。 + /// + public string? DestinationCountry { get; set; } + + /// + /// 服务渠道主键。 + /// + public int? ServiceChannelId { get; set; } + + /// + /// 服务渠道名称。 + /// + public string? ServiceChannelName { get; set; } + /// /// 仓库主键。 /// diff --git a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs index 809b136..660322c 100644 --- a/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs +++ b/src/Oms.Application/Dtos/Orders/ProductBillingSnapshotDtos.cs @@ -44,9 +44,9 @@ public class SalesProductRulesPublishSnapshotDto public SalesProductSaveInput? Product { get; set; } /// - /// 分配的服务商列表。 + /// 分配的服务商列表(发布快照,不含创建/更新时间)。 /// - public List Providers { get; set; } = []; + public List Providers { get; set; } = []; /// /// 黑名单分区 ID(发布时固化)。 @@ -109,6 +109,16 @@ public class SalesProductRulesPublishSnapshotDto public List WeightRoundingInfos { get; set; } = []; } +/// +/// 服务渠道买价发布快照。 +/// +public class ServiceChannelPricePublishSnapshotDto +{ + public int ServiceChannelId { get; set; } + + public List Prices { get; set; } = []; +} + /// /// 绑定到订单的快照版本信息。 /// diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index df8f7c4..0b4fad0 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -166,6 +166,8 @@ public partial class SalesProductService { Product = MapProductToRulesSaveInput(product), Providers = (await GetAllocationCoreAsync(salesProductId)).Providers + .Select(PublishSnapshotSlimmer.SlimAllocationProvider) + .ToList() }; await RulesSnapshotHelper.ApplyRulesToSnapshotAsync( rulesSnapshot, diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs index ccfa5ca..a6c1fab 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.Helpers.cs @@ -173,35 +173,56 @@ public partial class ServiceChannelService }; } + /// + /// 组装买价发布快照(不落库),与产品卖价 §5.3.2 对称。 + /// + private async Task BuildPricePublishSnapshotAsync(int serviceChannelId) + { + return new ServiceChannelPricePublishSnapshot + { + ServiceChannelId = serviceChannelId, + Prices = (await GetPriceOutputsAsync(serviceChannelId)) + .Select(PublishSnapshotSlimmer.SlimChannelPrice) + .ToList() + }; + } + + /// + /// 组装渠道规则发布快照(不落库)。 + /// + private async Task 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 + }; + /// /// 为当前配置生成发布快照。 /// 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 diff --git a/src/Oms.Application/Services/Channels/ServiceChannelService.cs b/src/Oms.Application/Services/Channels/ServiceChannelService.cs index e41f7f6..5738ef1 100644 --- a/src/Oms.Application/Services/Channels/ServiceChannelService.cs +++ b/src/Oms.Application/Services/Channels/ServiceChannelService.cs @@ -423,6 +423,18 @@ public partial class ServiceChannelService : ApplicationService return GetPublishListAsync(input, PriceOperatorType); } + /// + /// 预览即将发布的快照 JSON(不落库),与产品卖价 publish/preview 对称。 + /// + [HttpGet("/channel/service-channel/publish/preview")] + public async Task PreviewPublishSnapshotAsync([FromQuery] int serviceChannelId, [FromQuery] string operatorType) + { + var normalized = NormalizeOperatorType(operatorType); + return normalized == PriceOperatorType + ? await BuildPricePublishSnapshotAsync(serviceChannelId) + : await BuildRulesPublishSnapshotAsync(serviceChannelId); + } + /// /// 查询服务渠道发布规则列表。 /// diff --git a/src/Oms.Application/Services/Operations/OutboundRecordService.cs b/src/Oms.Application/Services/Operations/OutboundRecordService.cs index 2e92f1d..64bcc89 100644 --- a/src/Oms.Application/Services/Operations/OutboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/OutboundRecordService.cs @@ -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(); private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); + private OrderService OrderService => LazyServiceProvider.GetRequiredService(); /// /// 按单号查询订单(出库扫描展示销售产品、目的国、服务渠道,只读)。 @@ -92,12 +94,6 @@ public class OutboundRecordService : ApplicationService /// /// 入参:出库记录创建参数。 /// 出库记录结果。 - /// - /// 核心业务逻辑: - /// 1. 校验入参与关联对象、唯一性等前置条件。 - /// 2. 组装目标实体及相关明细数据。 - /// 3. 写入审计字段并持久化结果。 - /// [HttpPost("/operation/outbound/create")] public async Task 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(); 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(savedEntity); } @@ -134,12 +165,6 @@ public class OutboundRecordService : ApplicationService /// 入参:返回记录条数上限。 /// 入参:warehouseId。 /// 出库记录列表结果。 - /// - /// 核心业务逻辑: - /// 1. 根据查询条件构造筛选规则。 - /// 2. 查询目标数据并补齐关联展示字段。 - /// 3. 按接口约定返回列表结果。 - /// [HttpGet("/operation/outbound/recent-list")] public async Task> GetRecentListAsync([FromQuery] int limit = 20, [FromQuery] int? warehouseId = null) { @@ -151,21 +176,52 @@ public class OutboundRecordService : ApplicationService limit <= 0 ? 20 : limit, new Dictionary { [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(); + foreach (var entity in entities) { var output = ObjectMapper.Map(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 { 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)}」,不允许出库"); } } diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index a8c92ac..5b70c51 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -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 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> BuildChannelPriceOutputsAsync(int serviceChannelId) { var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index c4b45a4..a4f895f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -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); } } + /// + /// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。 + /// + private async Task ConfirmServiceChannelAndPayableAsync( + Order order, + SalesProduct product, + ProductSnapshotBinding snapshotBinding, + IReadOnlyList 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(); + + 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> 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>(provider.CustomerIds) ?? [] + })).ToList(); + } + + private sealed class ChannelPriceBinding + { + public List Prices { get; init; } = []; + + public string PriceVersion { get; init; } = string.Empty; + + public DateTime PriceEffectiveTime { get; init; } + } + + private async Task ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime) + { + var priceRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync( + serviceChannelId, + PriceOperatorType, + bindTime); + if (priceRecord?.Content != null) + { + var snapshot = OmsDocHelper.DeserializeJson(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 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 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 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 } /// - /// 入库收货后:覆盖 receive 件重尺、重算计费重,覆盖写入 inbound 阶段应收,并同步 ship/downstream 重量行。 + /// 入库收货后:仅覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并更新订单状态。 /// 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); } + /// + /// 出库发货后:仅覆盖 ship 件重尺、重算计费重,写入 outbound 阶段应收,并更新订单状态。 + /// + 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 BuildParcelCreateOutputAsync(Order order) { var mapped = (await MapOrderOutputsAsync([order])).First(); diff --git a/src/Oms.Application/Shared/AllocationResolver.cs b/src/Oms.Application/Shared/AllocationResolver.cs new file mode 100644 index 0000000..b31ca31 --- /dev/null +++ b/src/Oms.Application/Shared/AllocationResolver.cs @@ -0,0 +1,61 @@ +using Oms.Application.Dtos.Channels; +using Oms.Domain.ChannelsAggregate.Consts; +using Oms.Domain.ChannelsAggregate.Entities; + +namespace Oms.Application.Shared; + +/// +/// 排货规则运行时选渠。 +/// +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 FilterProviders( + IEnumerable 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 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 + }; + } +} diff --git a/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs b/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs index f622062..8805ebf 100644 --- a/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs +++ b/src/Oms.Application/Shared/PublishSnapshotSlimmer.cs @@ -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() { diff --git a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs index ebd12ec..0ae8a9f 100644 --- a/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs +++ b/src/Oms.Domain/ChannelsAggregate/Repositories/IServiceChannelRepository.cs @@ -49,4 +49,10 @@ public interface IServiceChannelRepository : IRepository Task InsertPublishRecordAsync(ServiceChannelPublishRecord record, CancellationToken cancellationToken = default); Task GetPublishRecordByIdAsync(int id, CancellationToken cancellationToken = default); + + Task GetEffectivePublishRecordAsync( + int serviceChannelId, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/OperationsAggregate/Entities/OutboundRecord.cs b/src/Oms.Domain/OperationsAggregate/Entities/OutboundRecord.cs index 6e999f1..6f8ff04 100644 --- a/src/Oms.Domain/OperationsAggregate/Entities/OutboundRecord.cs +++ b/src/Oms.Domain/OperationsAggregate/Entities/OutboundRecord.cs @@ -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; } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index d59444b..55f893e 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -41,8 +41,12 @@ public interface IOrderRepository : IRepository 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); diff --git a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs index ddf3794..9155af1 100644 --- a/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs +++ b/src/Oms.SqlSugarCore/Channels/ServiceChannelRepository.cs @@ -179,6 +179,24 @@ public class ServiceChannelRepository : OmsSqlSugarRepository, I return await db.Queryable().FirstAsync(x => x.Id == id, cancellationToken); } + public async Task GetEffectivePublishRecordAsync( + int serviceChannelId, + string operatorType, + DateTime bindTime, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var records = await db.Queryable() + .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, diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index 24403c2..f8ce891 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -216,6 +216,16 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .ExecuteCommandAsync(cancellationToken); } + public async Task DeleteReceivableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable() + .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, IOrderRepository, I .ExecuteCommandAsync(cancellationToken); } + public async Task DeletePayableFeesByStageAsync(int orderId, string chargingStage, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable() + .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); diff --git a/src/Oms.Tests/AllocationResolverTests.cs b/src/Oms.Tests/AllocationResolverTests.cs new file mode 100644 index 0000000..44c66b6 --- /dev/null +++ b/src/Oms.Tests/AllocationResolverTests.cs @@ -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 + { + 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 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(); + } +}