diff --git a/sql/20260801_order_allocation_trace.sql b/sql/20260801_order_allocation_trace.sql new file mode 100644 index 0000000..2f89baf --- /dev/null +++ b/sql/20260801_order_allocation_trace.sql @@ -0,0 +1,6 @@ +-- 订单排货过程快照(方案 + 候选指标,供详情「查看排货过程」) + +ALTER TABLE `t_order` + ADD COLUMN `AllocationScheme` VARCHAR(32) NULL COMMENT '最近一次排货方案 costLowest/timeFastest/overallBest/designated' AFTER `AllocationPendingReason`, + ADD COLUMN `AllocationTraceJson` JSON NULL COMMENT '最近一次排货过程快照 JSON' AFTER `AllocationScheme`, + ADD COLUMN `AllocationTraceTime` DATETIME NULL COMMENT '排货快照写入时间' AFTER `AllocationTraceJson`; diff --git a/sql/20260803_pickup_order.sql b/sql/20260803_pickup_order.sql new file mode 100644 index 0000000..6e710b1 --- /dev/null +++ b/sql/20260803_pickup_order.sql @@ -0,0 +1,78 @@ +-- 揽收单(独立履约/结算)+ 与物流单 1:N 关联;物流单冗余到仓方式与揽收 FK。 +-- 文档:前端 docs/揽收单与物流单串联方案.md +-- 执行前请备份。 + +CREATE TABLE IF NOT EXISTS `t_pickup_order` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `PickupNo` VARCHAR(32) NOT NULL COMMENT '揽收单号', + `CustomerId` INT NOT NULL COMMENT '客户Id', + `SenderAddressId` INT NOT NULL COMMENT '发件地址簿Id', + `SenderName` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '取件联系人快照', + `SenderPhone` VARCHAR(32) NULL COMMENT '取件电话快照', + `SenderCompany` VARCHAR(255) NULL COMMENT '公司快照', + `SenderCountryCode` VARCHAR(8) NULL COMMENT '国家码快照', + `SenderProvince` VARCHAR(50) NULL COMMENT '省快照', + `SenderCity` VARCHAR(50) NULL COMMENT '市快照', + `SenderZipCode` VARCHAR(50) NULL COMMENT '邮编快照', + `SenderStreetAddress1` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '地址1快照', + `SenderStreetAddress2` VARCHAR(500) NULL COMMENT '地址2快照', + `WarehouseId` INT NOT NULL COMMENT '送到仓库Id', + `CarrierCode` VARCHAR(64) NULL COMMENT '承运商编码(draft可空)', + `CarrierService` VARCHAR(64) NULL COMMENT '承运服务(draft可空)', + `CarrierWaybillNo` VARCHAR(64) NULL COMMENT '承运商回执单号', + `ExpectPickupTime` DATETIME NOT NULL COMMENT '期望揽收时间', + `PickupWindowStart` DATETIME NULL COMMENT '时间窗起', + `PickupWindowEnd` DATETIME NULL COMMENT '时间窗止', + `ActualPickupTime` DATETIME NULL COMMENT '实际取件时间', + `Status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/scheduled/dispatched/picked/inbound/cancelled/failed', + `ParcelCount` INT NOT NULL DEFAULT 0 COMMENT '关联票数', + `TotalActualWeight` DECIMAL(18,3) NULL COMMENT '汇总实重', + `ChargeableWeight` DECIMAL(18,3) NULL COMMENT '计费重', + `Currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种', + `FreightAmount` DECIMAL(18,2) NULL COMMENT '对客揽收运费', + `OtherFeeAmount` DECIMAL(18,2) NULL COMMENT '对客其他费', + `ReceivableAmount` DECIMAL(18,2) NULL COMMENT '对客应收合计', + `PayStatus` VARCHAR(16) NOT NULL DEFAULT 'unpaid' COMMENT 'unpaid/paid', + `PayTime` DATETIME NULL COMMENT '支付时间', + `PayMode` VARCHAR(16) NOT NULL DEFAULT 'prepaid' COMMENT 'prepaid/collect', + `CarrierPayableAmount` DECIMAL(18,2) NULL COMMENT '应付承运商', + `SettlementStatus` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT 'none/pending/settled', + `SettlementBatchNo` VARCHAR(64) NULL COMMENT '结算批次号', + `SettledAt` DATETIME NULL COMMENT '结算时间', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_pickup_no` (`PickupNo`), + KEY `idx_pickup_customer_status` (`CustomerId`, `Status`, `CreateTime`), + KEY `idx_pickup_warehouse_time` (`WarehouseId`, `ExpectPickupTime`), + KEY `idx_pickup_pay` (`PayStatus`), + KEY `idx_pickup_settle` (`SettlementStatus`) +) COMMENT='揽收单(上门取件履约+对客收款+承运结算)'; + +CREATE TABLE IF NOT EXISTS `t_pickup_order_line` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `PickupOrderId` INT NOT NULL COMMENT '揽收单Id', + `OrderId` INT NOT NULL COMMENT '物流订单Id', + `SortNo` INT NOT NULL DEFAULT 0 COMMENT '展示序', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_pickup_line_order` (`OrderId`), + UNIQUE KEY `uk_pickup_line_pair` (`PickupOrderId`, `OrderId`), + KEY `idx_pickup_line_header` (`PickupOrderId`, `IsDeleted`) +) COMMENT='揽收单关联物流订单(1揽收:N物流)'; + +ALTER TABLE `t_order` + ADD COLUMN `InboundMethod` VARCHAR(16) NULL COMMENT '到仓方式 selfShip/doorPickup' AFTER `CustomerSenderAddressId`, + ADD COLUMN `PickupOrderId` INT NULL COMMENT '关联揽收单Id(冗余)' AFTER `InboundMethod`; + +ALTER TABLE `t_order` + ADD KEY `idx_order_pickup` (`PickupOrderId`), + ADD KEY `idx_order_inbound_method` (`InboundMethod`); diff --git a/src/Oms.Application/Dtos/Operations/InboundOrderLookupOutput.cs b/src/Oms.Application/Dtos/Operations/InboundOrderLookupOutput.cs index 9dfb3ca..e5db913 100644 --- a/src/Oms.Application/Dtos/Operations/InboundOrderLookupOutput.cs +++ b/src/Oms.Application/Dtos/Operations/InboundOrderLookupOutput.cs @@ -59,4 +59,9 @@ public class InboundOrderLookupOutput /// 订单状态名称。 /// public string? OrderStatusName { get; set; } + + /// + /// 待排货/计费失败原因。 + /// + public string? AllocationPendingReason { get; set; } } diff --git a/src/Oms.Application/Dtos/Operations/InboundRecordOutput.cs b/src/Oms.Application/Dtos/Operations/InboundRecordOutput.cs index 6671d2a..e5979b6 100644 --- a/src/Oms.Application/Dtos/Operations/InboundRecordOutput.cs +++ b/src/Oms.Application/Dtos/Operations/InboundRecordOutput.cs @@ -75,6 +75,26 @@ public class InboundRecordOutput /// public string? OperatorName { get; set; } + /// + /// 关联订单主键。 + /// + public int? OrderId { get; set; } + + /// + /// 订单状态码。 + /// + public string? OrderStatus { get; set; } + + /// + /// 订单状态名称。 + /// + public string? OrderStatusName { get; set; } + + /// + /// 待排货/计费失败原因(有则展示)。 + /// + public string? AllocationPendingReason { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs b/src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs new file mode 100644 index 0000000..e1dc427 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs @@ -0,0 +1,81 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 订单排货过程(快照或现算)。 +/// +public class OrderAllocationTraceOutput +{ + public int OrderId { get; set; } + + /// true=历史快照;false=按当前规则现算。 + public bool FromSnapshot { get; set; } + + public string? Scheme { get; set; } + + public string? SchemeName { get; set; } + + /// productDefault / reallocate / designated + public string? SchemeSource { get; set; } + + public string? SchemeSourceName { get; set; } + + public decimal? ChargeableWeight { get; set; } + + public string? ChargingStage { get; set; } + + public string? ChargingStageName { get; set; } + + public string? SelectedChannelCode { get; set; } + + public string? SelectedChannelName { get; set; } + + public string? FailReason { get; set; } + + public DateTime? TraceTime { get; set; } + + public List Candidates { get; set; } = []; + + public List Skipped { get; set; } = []; +} + +public class OrderAllocationCandidateOutput +{ + public int ServiceChannelId { get; set; } + + public string ChannelCode { get; set; } = string.Empty; + + public string? ChannelName { get; set; } + + /// 应付合计(价格优先主指标)。 + public decimal TotalPayableCost { get; set; } + + /// 时效天数(时效优先主指标)。 + public int? MinTime { get; set; } + + /// 综合权重(综合最优主指标)。 + public int Weight { get; set; } + + public bool Selected { get; set; } + + public List FeeLines { get; set; } = []; +} + +public class OrderAllocationSkippedOutput +{ + public int? ServiceChannelId { get; set; } + + public string? ChannelCode { get; set; } + + public string? ChannelName { get; set; } + + public string Reason { get; set; } = string.Empty; +} + +public class OrderAllocationFeeLineOutput +{ + public string? CostItem { get; set; } + + public decimal Amount { get; set; } + + public string? Currency { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index c20c807..e681eff 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -125,6 +125,21 @@ public class OrderOutput /// public int? CustomerSenderAddressId { get; set; } + /// + /// 到仓方式:selfShip / doorPickup。 + /// + public string? InboundMethod { get; set; } + + /// + /// 关联揽收单 Id。 + /// + public int? PickupOrderId { get; set; } + + /// + /// 关联揽收单号。 + /// + public string? PickupNo { get; set; } + /// /// 幂等请求标识。 /// @@ -145,6 +160,16 @@ public class OrderOutput /// public string? AllocationPendingReason { get; set; } + /// + /// 最近一次排货方案。 + /// + public string? AllocationScheme { get; set; } + + /// + /// 是否已有排货过程快照。 + /// + public bool HasAllocationTrace { get; set; } + /// /// 申报币种。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs index 397e626..700ec1d 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateInput.cs @@ -154,4 +154,29 @@ public class OrderParcelCreateInput /// 仅保存为草稿,不执行预报计费与状态推进。 /// public bool SaveAsDraft { get; set; } + + /// + /// 到仓方式:selfShip / doorPickup。 + /// + public string? InboundMethod { get; set; } + + /// + /// 上门揽收时发件地址簿 Id。 + /// + public int? SenderAddressId { get; set; } + + /// + /// 期望揽收时间(上门揽收)。 + /// + public DateTime? ExpectPickupTime { get; set; } + + /// + /// 承运商编码(可后补)。 + /// + public string? CarrierCode { get; set; } + + /// + /// 承运服务(可后补)。 + /// + public string? CarrierService { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs index 19a593c..6e9b014 100644 --- a/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderParcelCreateOutput.cs @@ -39,4 +39,19 @@ public class OrderParcelCreateOutput /// 收件国家/地区中文名称。 /// public string? ConsigneeCountryName { get; set; } + + /// + /// 同步创建的揽收单号(上门揽收成功时)。 + /// + public string? PickupNo { get; set; } + + /// + /// 同步创建的揽收单 Id。 + /// + public int? PickupOrderId { get; set; } + + /// + /// 揽收单创建失败原因(运单已成功时仅作提示)。 + /// + public string? PickupCreateError { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/OrderReallocateDtos.cs b/src/Oms.Application/Dtos/Orders/OrderReallocateDtos.cs new file mode 100644 index 0000000..ea58b9c --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderReallocateDtos.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 单票重新排货入参。 +/// +public class OrderReallocateInput +{ + [Required] + public int Id { get; set; } + + /// + /// 排货方案:costLowest / timeFastest / overallBest / designated。 + /// + [Required] + [StringLength(32)] + public string Scheme { get; set; } = string.Empty; + + /// + /// 指定渠道时必填。 + /// + public int? ServiceChannelId { get; set; } +} + +/// +/// 批量重新排货入参(同步兜底)。 +/// +public class OrderReallocateBatchInput +{ + [Required] + [MinLength(1)] + public List OrderIds { get; set; } = []; + + [Required] + [StringLength(32)] + public string Scheme { get; set; } = string.Empty; + + public int? ServiceChannelId { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs new file mode 100644 index 0000000..4eadb95 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs @@ -0,0 +1,202 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +public class PickupOrderCreateInput +{ + public int? CustomerId { get; set; } + + [Required] + public int SenderAddressId { get; set; } + + [Required] + public int WarehouseId { get; set; } + + public string? CarrierCode { get; set; } + + public string? CarrierService { get; set; } + + [Required] + public DateTime ExpectPickupTime { get; set; } + + [Required] + [MinLength(1)] + public List OrderIds { get; set; } = []; + + [StringLength(512)] + public string? Remark { get; set; } + + /// + /// 可选:创建时直接写入对客运费(未写则保持未确认)。 + /// + public decimal? FreightAmount { get; set; } +} + +public class PickupOrderIdInput +{ + [Required] + public int Id { get; set; } +} + +public class PickupOrderOrdersInput +{ + [Required] + public int Id { get; set; } + + [Required] + [MinLength(1)] + public List OrderIds { get; set; } = []; +} + +public class PickupOrderListInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 30; + + public int? CustomerId { get; set; } + + public string? Status { get; set; } + + public string? PayStatus { get; set; } + + public string? Keyword { get; set; } + + public DateTime? CreateTimeStart { get; set; } + + public DateTime? CreateTimeEnd { get; set; } +} + +public class PickupOrderConfirmFreightInput +{ + [Required] + public int Id { get; set; } + + [Required] + public decimal FreightAmount { get; set; } + + public decimal? OtherFeeAmount { get; set; } + + public decimal? CarrierPayableAmount { get; set; } +} + +public class PickupOrderScheduleInput +{ + [Required] + public int Id { get; set; } + + [Required] + public string CarrierCode { get; set; } = string.Empty; + + public string? CarrierService { get; set; } + + public DateTime? ExpectPickupTime { get; set; } +} + +public class PickupOrderRescheduleInput +{ + [Required] + public int Id { get; set; } + + [Required] + public DateTime ExpectPickupTime { get; set; } +} + +public class PickupOrderRejectItem +{ + public int OrderId { get; set; } + + public string? ServiceNo { get; set; } + + public string Reason { get; set; } = string.Empty; +} + +public class PickupOrderLineOutput +{ + public int OrderId { get; set; } + + public string? ServiceNo { get; set; } + + public string? ClientNo { get; set; } + + public string? OrderStatus { get; set; } + + public int? WarehouseId { get; set; } + + public decimal? ChargeableWeight { get; set; } +} + +public class PickupOrderOutput +{ + public int Id { get; set; } + + public string PickupNo { get; set; } = string.Empty; + + public int CustomerId { get; set; } + + public string? CustomerName { get; set; } + + public int SenderAddressId { get; set; } + + public string SenderName { get; set; } = string.Empty; + + public string? SenderPhone { get; set; } + + public string? SenderCompany { get; set; } + + public string? SenderProvince { get; set; } + + public string? SenderCity { get; set; } + + public string? SenderStreetAddress1 { get; set; } + + public int WarehouseId { get; set; } + + public string? WarehouseName { get; set; } + + public string? CarrierCode { get; set; } + + public string? CarrierService { get; set; } + + public string? CarrierWaybillNo { get; set; } + + public DateTime ExpectPickupTime { get; set; } + + public DateTime? ActualPickupTime { get; set; } + + public string Status { get; set; } = string.Empty; + + public int ParcelCount { get; set; } + + public decimal? TotalActualWeight { get; set; } + + public decimal? ChargeableWeight { get; set; } + + public string Currency { get; set; } = "CNY"; + + public decimal? FreightAmount { get; set; } + + public decimal? OtherFeeAmount { get; set; } + + public decimal? ReceivableAmount { get; set; } + + public string PayStatus { get; set; } = string.Empty; + + public DateTime? PayTime { get; set; } + + public string PayMode { get; set; } = string.Empty; + + public decimal? CarrierPayableAmount { get; set; } + + public string SettlementStatus { get; set; } = string.Empty; + + public string? SettlementBatchNo { get; set; } + + public string? Remark { get; set; } + + public DateTime? CreateTime { get; set; } + + public List Orders { get; set; } = []; + + public List Rejected { get; set; } = []; +} diff --git a/src/Oms.Application/Services/Customers/CustomerAccountFlowService.cs b/src/Oms.Application/Services/Customers/CustomerAccountFlowService.cs index 319b263..c1124f4 100644 --- a/src/Oms.Application/Services/Customers/CustomerAccountFlowService.cs +++ b/src/Oms.Application/Services/Customers/CustomerAccountFlowService.cs @@ -140,6 +140,7 @@ public class CustomerAccountFlowService : ApplicationService { CustomerAccountFlowBizTypes.Recharge => "充值入账", CustomerAccountFlowBizTypes.OrderPay => "订单支付", + CustomerAccountFlowBizTypes.PickupPay => "揽收支付", _ => bizType }; } diff --git a/src/Oms.Application/Services/Operations/InboundRecordService.cs b/src/Oms.Application/Services/Operations/InboundRecordService.cs index 2dda5d6..5d6a56d 100644 --- a/src/Oms.Application/Services/Operations/InboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/InboundRecordService.cs @@ -8,6 +8,8 @@ using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OperationsAggregate.Entities; using Oms.Domain.OperationsAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; using Oms.Domain.SystemAggregate.Repositories; @@ -54,7 +56,8 @@ public class InboundRecordService : ApplicationService ProductCode = order.ProductCode, DestinationCountry = order.ConsigneeCountry, OrderStatus = order.OrderStatus, - OrderStatusName = OmsDocHelper.GetOrderStatusName(order.OrderStatus) + OrderStatusName = OmsDocHelper.GetOrderStatusName(order.OrderStatus), + AllocationPendingReason = order.AllocationPendingReason }; if (!string.IsNullOrWhiteSpace(order.ProductCode)) @@ -103,6 +106,7 @@ public class InboundRecordService : ApplicationService "收货"); ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus); + await EnsureInboundNotInFlightAsync(order, input.WaybillNo.Trim()); int? salesProductId = null; string? destinationCountry = order.ConsigneeCountry; @@ -137,20 +141,13 @@ public class InboundRecordService : ApplicationService var savedEntity = await InboundRecordRepository.InsertAsync(entity); - try - { - var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim()); - if (refreshed != null) - { - await AutoTriggerRuntimeService.FireAsync(refreshed, AutoTriggerTiming.Inbound, operatorId); - } - } - catch - { - // 自动触发失败不影响入库主流程 - } + // 自动触发改异步,避免拉长扫码 RT + AutoTriggerRuntimeService.EnqueueFire(order.Id, AutoTriggerTiming.Inbound, operatorId); - return ObjectMapper.Map(savedEntity); + var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim()); + var output = ObjectMapper.Map(savedEntity); + ApplyOrderStatusToInboundOutput(output, refreshed); + return output; } /// @@ -181,6 +178,14 @@ public class InboundRecordService : ApplicationService var productMap = products.ToDictionary(x => x.Id); var warehouseMap = warehouses.ToDictionary(x => x.Id); + var waybillNos = entities + .Select(x => x.WaybillNo?.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Cast() + .ToArray(); + var orderMap = await BuildOrderMapByWaybillNosAsync(waybillNos); + var items = entities.Select(entity => { var output = ObjectMapper.Map(entity); @@ -191,9 +196,88 @@ public class InboundRecordService : ApplicationService ? warehouse.WarehouseName : null; output.OperatorName = entity.OperatorId?.ToString(); + var key = entity.WaybillNo?.Trim() ?? string.Empty; + if (orderMap.TryGetValue(key, out var order)) + { + ApplyOrderStatusToInboundOutput(output, order); + } + return output; }).ToList(); return new OmsItemsResult { Items = items }; } + + private async Task> BuildOrderMapByWaybillNosAsync(string[] waybillNos) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (waybillNos.Length == 0) + { + return map; + } + + var orders = await OrderRepository.GetListAsync(x => + waybillNos.Contains(x.ServiceNo) + || (x.TrackNo != null && waybillNos.Contains(x.TrackNo)) + || (x.DownstreamServiceNo != null && waybillNos.Contains(x.DownstreamServiceNo)) + || (x.ClientNo != null && waybillNos.Contains(x.ClientNo))); + + foreach (var order in orders) + { + void Put(string? key) + { + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + map[key.Trim()] = order; + } + + Put(order.ServiceNo); + Put(order.TrackNo); + Put(order.DownstreamServiceNo); + Put(order.ClientNo); + } + + return map; + } + + private static void ApplyOrderStatusToInboundOutput(InboundRecordOutput output, Order? order) + { + if (order == null) + { + return; + } + + output.OrderId = order.Id; + output.OrderStatus = order.OrderStatus; + output.OrderStatusName = OmsDocHelper.GetOrderStatusName(order.OrderStatus); + output.AllocationPendingReason = order.AllocationPendingReason; + } + + /// + /// 短时幂等:处理中重复扫码直接拒绝,避免双写入库记录与双投计费任务。 + /// + private async Task EnsureInboundNotInFlightAsync(Order order, string waybillNo) + { + const int windowSeconds = 15; + var now = DateTime.Now; + + if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase) + && string.Equals(order.AllocationPendingReason?.Trim(), "入库后计费/排货处理中", StringComparison.Ordinal) + && order.ReceiveTime.HasValue + && (now - order.ReceiveTime.Value).TotalSeconds < windowSeconds) + { + throw new BusinessException("订单入库处理中,请勿重复扫描"); + } + + var latest = await InboundRecordRepository.GetLatestByWaybillNoAsync(waybillNo); + if (latest != null + && (now - latest.OperateTime).TotalSeconds < windowSeconds + && string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("订单入库处理中,请勿重复扫描"); + } + } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs index 3d95a99..ab02c47 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs @@ -43,6 +43,8 @@ public sealed class OrderBatchActionExecutor( OrderBatchActionCodes.Delete => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.Print => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.GenerateLabelPdf => await orderActionHandler.ExecuteAsync(message, cancellationToken), + OrderBatchActionCodes.InboundBilling => await orderActionHandler.ExecuteAsync(message, cancellationToken), + OrderBatchActionCodes.Reallocate => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.HandleStatusUpdate => await orderActionHandler.ExecuteAsync(message, cancellationToken), diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs index f54422f..b8f75fd 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobItemFeedback.cs @@ -268,6 +268,8 @@ public static class OrderBatchJobItemFeedback OrderBatchActionCodes.Delete => "删除成功", OrderBatchActionCodes.Print => "打印成功", OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功", + OrderBatchActionCodes.InboundBilling => "入库计费/排货完成", + OrderBatchActionCodes.Reallocate => "重新排货成功", OrderBatchActionCodes.MarkProblematic => "问题件创建成功", OrderBatchActionCodes.MarkClaimed => "理赔件创建成功", OrderBatchActionCodes.HandleStatusUpdate => "处理状态更新成功", diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs index 4e48e98..f578a60 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs @@ -63,6 +63,17 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto resultJson: JsonConvert.SerializeObject(new { labelUrl = result.LabelUrl })); } + case OrderBatchActionCodes.InboundBilling: + await orderService.ProcessInboundBillingForBatchAsync(message.OrderId, cancellationToken); + return OrderBatchActionExecuteResult.Ok(); + + case OrderBatchActionCodes.Reallocate: + await orderService.ReallocateForBatchAsync( + message.OrderId, + message.PayloadJson, + cancellationToken); + return OrderBatchActionExecuteResult.Ok(); + case OrderBatchActionCodes.MarkProblematic: await orderService.MarkProblematicForBatchAsync( message.OrderId, diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index 8d06daa..7af917c 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -1,4 +1,5 @@ using Fake.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; @@ -15,10 +16,40 @@ public class AutoTriggerRuntimeService( OrderAutoTriggerService orderAutoTriggerService, OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, + IServiceScopeFactory scopeFactory, ILogger logger) : IScopedDependency { private const string InterceptOperation = "拦截"; + /// + /// 后台异步触发,避免拉长入库/出库扫码请求。 + /// + public void EnqueueFire(int orderId, AutoTriggerTiming timing, int operatorUserId) + { + if (orderId <= 0) + { + return; + } + + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var runtime = scope.ServiceProvider.GetRequiredService(); + await runtime.FireByOrderIdAsync(orderId, timing, operatorUserId); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "异步自动触发失败 OrderId={OrderId} Timing={Timing}", + orderId, + timing); + } + }); + } + public async Task FireAsync(Order order, AutoTriggerTiming timing, int operatorUserId) { if (order.Id <= 0) diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs index a82e40e..61120b2 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchJob.cs @@ -166,6 +166,13 @@ public partial class OrderService public Task BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) => EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input); + /// + /// 异步批量重新排货。 + /// + [HttpPost("/order/batch/reallocate")] + public Task BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) => + EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input); + /// /// 异步批量标记已认领。 /// diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs index cc2bbb0..d15d384 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs @@ -110,6 +110,18 @@ public partial class OrderService }); } + internal async Task ReallocateForBatchAsync( + int orderId, + string? payloadJson, + CancellationToken cancellationToken = default) + { + _ = cancellationToken; + var input = DeserializeBatchPayload(payloadJson) + ?? throw new BusinessException("重新排货参数无效"); + var order = await GetOrderOrThrowAsync(orderId); + await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId); + } + internal async Task UpdateHandleStatusForBatchAsync( int orderId, string? payloadJson, diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 2f3b88b..e2bfe80 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -89,6 +89,16 @@ public partial class OrderService : await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id)); var customerMap = customers.ToDictionary(x => x.Id); + var pickupOrderIds = orders + .Where(x => x.PickupOrderId is > 0) + .Select(x => x.PickupOrderId!.Value) + .Distinct() + .ToArray(); + var pickupOrders = pickupOrderIds.Length == 0 + ? [] + : await PickupOrderRepository.GetListAsync(x => pickupOrderIds.Contains(x.Id)); + var pickupNoMap = pickupOrders.ToDictionary(x => x.Id, x => x.PickupNo); + var outputs = orders.Select(order => { senderMap.TryGetValue(order.Id, out var sender); @@ -149,6 +159,12 @@ public partial class OrderService : order.OrderSource, PickupTime = order.PickupTime, CustomerSenderAddressId = order.CustomerSenderAddressId, + InboundMethod = order.InboundMethod, + PickupOrderId = order.PickupOrderId, + PickupNo = order.PickupOrderId is > 0 + && pickupNoMap.TryGetValue(order.PickupOrderId.Value, out var pickupNo) + ? pickupNo + : null, RequestId = order.RequestId, ExportCustomsMode = order.ExportCustomsMode, PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason) @@ -157,6 +173,10 @@ public partial class OrderService AllocationPendingReason = string.IsNullOrWhiteSpace(order.AllocationPendingReason) ? null : order.AllocationPendingReason.Trim(), + AllocationScheme = string.IsNullOrWhiteSpace(order.AllocationScheme) + ? null + : order.AllocationScheme.Trim(), + HasAllocationTrace = !string.IsNullOrWhiteSpace(order.AllocationTraceJson), DeclaredCurrency = order.DeclaredCurrency, DeclaredSum = order.DeclaredSum, Quantity = order.Quantity, diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index c9eea5e..3350981 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -1,8 +1,10 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; 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.Enums; @@ -246,6 +248,193 @@ public partial class OrderService return (await MapOrderOutputsAsync([order])).First(); } + /// + /// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。 + /// + [HttpPost("/order/fee/rerun-inbound-billing")] + public async Task RerunInboundBillingAsync(OrderActionInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + await ProcessInboundBillingForBatchAsync(order.Id); + order = await GetOrderOrThrowAsync(input.Id); + return (await MapOrderOutputsAsync([order])).First(); + } + + /// + /// 查询排货过程:优先返回快照;无快照或 live=true 时按当前规则现算(不落库)。 + /// + [HttpGet("/order/allocation-trace")] + public async Task GetAllocationTraceAsync( + [FromQuery] int id, + [FromQuery] bool live = false) + { + var order = await GetOrderOrThrowAsync(id); + if (!live && !string.IsNullOrWhiteSpace(order.AllocationTraceJson)) + { + try + { + var snapshot = JsonConvert.DeserializeObject(order.AllocationTraceJson); + if (snapshot != null) + { + snapshot.OrderId = order.Id; + snapshot.FromSnapshot = true; + snapshot.TraceTime = order.AllocationTraceTime ?? snapshot.TraceTime; + if (string.IsNullOrWhiteSpace(snapshot.Scheme)) + { + snapshot.Scheme = order.AllocationScheme; + snapshot.SchemeName = ResolveAllocationSchemeName(order.AllocationScheme); + } + + snapshot.Candidates = SortAllocationCandidates(snapshot.Scheme, snapshot.Candidates ?? []); + return snapshot; + } + } + catch + { + // 快照损坏则走现算 + } + } + + return await BuildLiveAllocationTraceAsync(order); + } + + private async Task BuildLiveAllocationTraceAsync(Order order) + { + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + return new OrderAllocationTraceOutput + { + OrderId = order.Id, + FromSnapshot = false, + FailReason = "订单未绑定销售产品", + TraceTime = DateTime.Now + }; + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null) + { + return new OrderAllocationTraceOutput + { + OrderId = order.Id, + FromSnapshot = false, + FailReason = "销售产品不存在", + TraceTime = DateTime.Now + }; + } + + try + { + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + var weightRow = ResolvePayableWeightRow(order, weights); + var chargeable = weightRow.ChargeableWeight ?? 0; + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + var billing = await ResolveOrderBillingContextAsync( + order, + product, + chargeable, + snapshotBinding); + var (_, chargingStage) = ResolveReceivableBillingStage(order); + + string? schemeOverride = null; + int? forceChannelId = null; + if (AllocationSchemeCodes.IsDesignated(order.AllocationScheme) + && !string.IsNullOrWhiteSpace(order.ChannelCode)) + { + var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => + x.ChannelCode == order.ChannelCode.Trim()); + if (channel != null) + { + schemeOverride = AllocationSchemeCodes.Designated; + forceChannelId = channel.Id; + } + } + else if (AllocationSchemeCodes.IsAutoScheme(order.AllocationScheme)) + { + schemeOverride = order.AllocationScheme; + } + + var evaluation = await EvaluateAllocationAsync( + order, + product, + billing.SnapshotBinding, + billing.ZoneKeys, + chargeable, + chargingStage, + schemeOverride, + forceChannelId); + + var output = BuildAllocationTraceOutput( + order.Id, + evaluation, + fromSnapshot: false, + selectedChannelCode: evaluation.Selected?.Channel.ChannelCode ?? order.ChannelCode); + output.FromSnapshot = false; + if (string.IsNullOrWhiteSpace(output.FailReason) + && !string.IsNullOrWhiteSpace(order.AllocationPendingReason) + && evaluation.Selected == null) + { + output.FailReason = order.AllocationPendingReason; + } + + return output; + } + catch (Exception ex) + { + return new OrderAllocationTraceOutput + { + OrderId = order.Id, + FromSnapshot = false, + Scheme = order.AllocationScheme, + SchemeName = ResolveAllocationSchemeName(order.AllocationScheme), + FailReason = ex is BusinessException be ? be.Message : ex.Message, + TraceTime = DateTime.Now + }; + } + } + + /// + /// 单票重新排货:仅重写应付与服务渠道,不动应收。 + /// + [HttpPost("/order/fee/reallocate")] + public async Task ReallocateAsync([FromBody] OrderReallocateInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId); + order = await GetOrderOrThrowAsync(input.Id); + return (await MapOrderOutputsAsync([order])).First(); + } + + /// + /// 批量重新排货(同步兜底,队列关闭时使用)。 + /// + [HttpPost("/order/fee/reallocate-batch")] + public async Task ReallocateBatchAsync([FromBody] OrderReallocateBatchInput input) + { + NormalizeReallocateOptions(input.Scheme, input.ServiceChannelId, out var scheme, out var channelId); + var result = new OrderBatchActionResultOutput(); + var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList(); + foreach (var orderId in orderIds) + { + try + { + var order = await GetOrderOrThrowAsync(orderId); + await ReallocateCoreAsync(order, scheme, channelId); + result.SuccessCount++; + } + catch (Exception ex) + { + result.Failures.Add(new OrderBatchActionFailureOutput + { + OrderId = orderId, + Reason = ex is BusinessException be ? be.Message : ex.Message + }); + } + } + + return result; + } + /// /// 未付款订单更新附加服务并重算当前阶段应收(可选尝试重算应付)。 /// @@ -416,6 +605,29 @@ public partial class OrderService private async Task RecalculatePayableCoreAsync(Order order) { + await ReallocateCoreAsync(order, scheme: null, serviceChannelId: null, enforceReallocateGate: false); + } + + /// + /// 重新排货核心:按方案/指定渠道确认服务渠道并重写应付。 + /// + private async Task ReallocateCoreAsync( + Order order, + string? scheme, + int? serviceChannelId, + bool enforceReallocateGate = true) + { + string? schemeOverride = null; + int? forceChannelId = null; + if (enforceReallocateGate) + { + EnsureReallocateAllowed(order); + NormalizeReallocateOptions(scheme, serviceChannelId, out var normalizedScheme, out forceChannelId); + schemeOverride = AllocationSchemeCodes.IsDesignated(normalizedScheme) + ? AllocationSchemeCodes.Designated + : normalizedScheme; + } + if (string.IsNullOrWhiteSpace(order.ProductCode)) { throw new BusinessException("订单未绑定销售产品,无法重算应付"); @@ -447,7 +659,9 @@ public partial class OrderService order, product, billing, - chargingStage); + chargingStage, + schemeOverride, + forceChannelId); if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase)) { @@ -458,6 +672,52 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); } + private static void EnsureReallocateAllowed(Order order) + { + if (order.IsPaid != 0) + { + throw new BusinessException("订单已付款,不允许重新排货"); + } + + if (order.OrderStatus is not (OrderStatusCodes.PendingAllocation or OrderStatusCodes.PendingPayment)) + { + throw new BusinessException( + $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅待排货/待支付可重新排货"); + } + } + + private static void NormalizeReallocateOptions( + string? scheme, + int? serviceChannelId, + out string normalizedScheme, + out int? forceServiceChannelId) + { + normalizedScheme = (scheme ?? string.Empty).Trim(); + forceServiceChannelId = null; + + if (AllocationSchemeCodes.IsDesignated(normalizedScheme)) + { + if (serviceChannelId is not > 0) + { + throw new BusinessException("指定渠道时请选择服务渠道"); + } + + forceServiceChannelId = serviceChannelId; + return; + } + + if (!AllocationSchemeCodes.IsAutoScheme(normalizedScheme)) + { + throw new BusinessException("排货方案无效,请选择价格优先/时效优先/综合最优/指定渠道"); + } + + if (serviceChannelId is > 0) + { + // 自动方案忽略渠道入参,避免误传 + forceServiceChannelId = null; + } + } + private static OrderWeight ResolvePayableWeightRow(Order order, IReadOnlyList weights) { var (_, chargingStage) = ResolveReceivableBillingStage(order); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 5108eb0..798d9ff 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -1,12 +1,16 @@ +using System.Collections.Concurrent; using Fake.Application; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.SmallBagTrack; using Oms.Application.Labeling; using Oms.Application.Messaging; using Oms.Application.Shared; +using Oms.Application.Services.Orders.Batch; using Oms.Application.Services.Orders.Issues; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; @@ -17,6 +21,7 @@ using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.MarketingAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; using Oms.Domain.Shared.Enums; using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Repositories; @@ -28,6 +33,10 @@ public partial class OrderService private const string PriceOperatorType = "price"; private const string RulesOperatorType = "rules"; private const int ParcelBatchMaxCount = 2000; + private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; + private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; + + private static readonly ConcurrentDictionary InboundBillingInFlight = new(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); @@ -55,6 +64,8 @@ public partial class OrderService public DateTime? PickupTime { get; init; } public int? CustomerSenderAddressId { get; init; } + + public string? InboundMethod { get; init; } } private async Task CreateParcelCoreAsync( @@ -752,28 +763,162 @@ public partial class OrderService IReadOnlyList zoneKeys, decimal chargeableWeight, string chargingStage, - string? _) + string? _, + string? schemeOverride = null, + int? forceServiceChannelId = null) { + var evaluation = await EvaluateAllocationAsync( + order, + product, + snapshotBinding, + zoneKeys, + chargeableWeight, + chargingStage, + schemeOverride, + forceServiceChannelId); + + ApplyAllocationTraceToOrder(order, evaluation); + + if (!string.IsNullOrWhiteSpace(evaluation.FailReason) || evaluation.Selected == null) + { + throw new BusinessException(evaluation.FailReason ?? "排货规则未匹配到可用服务渠道"); + } + + var selectedCandidate = evaluation.Selected; + var payableFees = selectedCandidate.PayableFees.ToList(); + if (payableFees.Count == 0) + { + evaluation.FailReason = "渠道买价未配置或未匹配到有效价格"; + ApplyAllocationTraceToOrder(order, evaluation); + throw new BusinessException(evaluation.FailReason); + } + + order.ChannelCode = selectedCandidate.Channel.ChannelCode; + AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder( + order, + selectedCandidate.Provider, + selectedCandidate.Channel); + // 有效应付只保留本阶段一套,避免 forecast+inbound 叠行虚增成本。 + await OrderRepository.DeletePayableFeesAsync(order.Id); + await OrderRepository.UpsertFeesAsync(order.Id, payableFees); + order.AllocationPendingReason = string.Empty; + } + + private sealed class AllocationEvaluation + { + public string StoredScheme { get; init; } = AllocationSchemeCodes.CostLowest; + public string SelectScheme { get; init; } = AllocationSchemeCodes.CostLowest; + public string SchemeSource { get; init; } = "productDefault"; + public decimal ChargeableWeight { get; init; } + public string ChargingStage { get; init; } = string.Empty; + public List Candidates { get; init; } = []; + public List Skipped { get; init; } = []; + public AllocationResolver.ChannelCandidate? Selected { get; set; } + public string? FailReason { get; set; } + } + + private async Task EvaluateAllocationAsync( + Order order, + SalesProduct product, + ProductSnapshotBinding snapshotBinding, + IReadOnlyList zoneKeys, + decimal chargeableWeight, + string chargingStage, + string? schemeOverride, + int? forceServiceChannelId) + { + var skipped = new List(); var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId); var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules); var filtered = AllocationResolver.FilterProviders(providers, order.CustomerId); + + var productScheme = snapshotBinding.Rules.Product?.Scheme + ?? product.Scheme + ?? AllocationSchemeCodes.CostLowest; + var hasOverride = !string.IsNullOrWhiteSpace(schemeOverride); + var isDesignated = forceServiceChannelId is > 0 + || AllocationSchemeCodes.IsDesignated(schemeOverride); + var storedScheme = isDesignated + ? AllocationSchemeCodes.Designated + : hasOverride + ? schemeOverride!.Trim() + : productScheme; + var schemeSource = isDesignated + ? "designated" + : hasOverride + ? "reallocate" + : "productDefault"; + var selectScheme = AllocationSchemeCodes.IsDesignated(storedScheme) + ? AllocationSchemeCodes.CostLowest + : storedScheme; + + if (forceServiceChannelId is > 0) + { + var before = filtered; + filtered = filtered + .Where(x => x.ServiceChannelId == forceServiceChannelId.Value) + .ToList(); + if (filtered.Count == 0) + { + foreach (var p in before.Where(x => x.ServiceChannelId != forceServiceChannelId)) + { + // 非指定渠道不记跳过;指定不在池内单独失败 + } + + return new AllocationEvaluation + { + StoredScheme = storedScheme, + SelectScheme = selectScheme, + SchemeSource = schemeSource, + ChargeableWeight = chargeableWeight, + ChargingStage = chargingStage, + Skipped = skipped, + FailReason = "指定渠道不在该销售产品排货池或当前客户不可用" + }; + } + } + if (filtered.Count == 0) { - throw new BusinessException("排货规则未匹配到可用服务渠道"); + return new AllocationEvaluation + { + StoredScheme = storedScheme, + SelectScheme = selectScheme, + SchemeSource = schemeSource, + ChargeableWeight = chargeableWeight, + ChargingStage = chargingStage, + Skipped = skipped, + FailReason = "排货规则未匹配到可用服务渠道" + }; } var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime; - var scheme = snapshotBinding.Rules.Product?.Scheme - ?? product.Scheme - ?? AllocationSchemeCodes.CostLowest; var candidates = new List(); var recipient = (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault(); foreach (var provider in filtered) { - var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == provider.ServiceChannelId!.Value); - if (channel == null || channel.IsActive != EnableStatus.Enabled) + var channelId = provider.ServiceChannelId!.Value; + var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId); + if (channel == null) { + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = channelId, + Reason = "服务渠道不存在" + }); + continue; + } + + if (channel.IsActive != EnableStatus.Enabled) + { + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = channel.Id, + ChannelCode = channel.ChannelCode, + ChannelName = channel.ChannelName, + Reason = "服务渠道未启用" + }); continue; } @@ -783,7 +928,6 @@ public partial class OrderService recipient?.Province, recipient?.City, recipient?.ZipCode); - // 买价计费条件用渠道偏远命中,不覆盖订单上的产品偏远标记。 var payableContext = BuildBillingConditionContext( order, channelIsRemote ? (byte)1 : (byte)0); @@ -808,6 +952,13 @@ public partial class OrderService channelRemoteZoneKeys); if (previewFees.Count == 0) { + skipped.Add(new OrderAllocationSkippedOutput + { + ServiceChannelId = channel.Id, + ChannelCode = channel.ChannelCode, + ChannelName = channel.ChannelName, + Reason = "未匹配到有效渠道买价" + }); continue; } @@ -822,37 +973,148 @@ public partial class OrderService if (candidates.Count == 0) { - if (chargeableWeight <= 0) + return new AllocationEvaluation { - throw new BusinessException("计费重无效,无法计算应付费用"); - } - - throw new BusinessException("排货规则未匹配到有效渠道买价"); + StoredScheme = storedScheme, + SelectScheme = selectScheme, + SchemeSource = schemeSource, + ChargeableWeight = chargeableWeight, + ChargingStage = chargingStage, + Candidates = candidates, + Skipped = skipped, + FailReason = chargeableWeight <= 0 + ? "计费重无效,无法计算应付费用" + : "排货规则未匹配到有效渠道买价" + }; } - var selectedCandidate = AllocationResolver.SelectCandidate(scheme, candidates); - var payableFees = selectedCandidate.PayableFees.ToList(); - if (payableFees.Count == 0) + var selected = AllocationResolver.SelectCandidate(selectScheme, candidates); + return new AllocationEvaluation { - throw new BusinessException("渠道买价未配置或未匹配到有效价格"); - } - - order.ChannelCode = selectedCandidate.Channel.ChannelCode; - AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder( - order, - selectedCandidate.Provider, - selectedCandidate.Channel); - // 有效应付只保留本阶段一套,避免 forecast+inbound 叠行虚增成本。 - await OrderRepository.DeletePayableFeesAsync(order.Id); - await OrderRepository.UpsertFeesAsync(order.Id, payableFees); - order.AllocationPendingReason = string.Empty; + StoredScheme = storedScheme, + SelectScheme = selectScheme, + SchemeSource = schemeSource, + ChargeableWeight = chargeableWeight, + ChargingStage = chargingStage, + Candidates = candidates, + Skipped = skipped, + Selected = selected + }; } + private static void ApplyAllocationTraceToOrder(Order order, AllocationEvaluation evaluation) + { + var selectedCode = evaluation.Selected?.Channel.ChannelCode; + var output = BuildAllocationTraceOutput( + order.Id, + evaluation, + fromSnapshot: true, + selectedChannelCode: selectedCode); + order.AllocationScheme = evaluation.StoredScheme; + order.AllocationTraceJson = JsonConvert.SerializeObject(output); + order.AllocationTraceTime = DateTime.Now; + } + + private static OrderAllocationTraceOutput BuildAllocationTraceOutput( + int orderId, + AllocationEvaluation evaluation, + bool fromSnapshot, + string? selectedChannelCode) + { + var selectedId = evaluation.Selected?.Channel.Id; + var candidates = evaluation.Candidates + .Select(c => new OrderAllocationCandidateOutput + { + ServiceChannelId = c.Channel.Id, + ChannelCode = c.Channel.ChannelCode, + ChannelName = c.Channel.ChannelName, + TotalPayableCost = c.TotalPayableCost, + MinTime = c.Channel.MinTime, + Weight = c.Provider.Weight, + Selected = selectedId.HasValue && c.Channel.Id == selectedId.Value, + FeeLines = c.PayableFees.Select(f => new OrderAllocationFeeLineOutput + { + CostItem = f.CostItem, + Amount = f.Amount, + Currency = f.Currency + }).ToList() + }) + .ToList(); + + // 按方案主指标排序,选中置顶 + candidates = SortAllocationCandidates(evaluation.StoredScheme, candidates); + + return new OrderAllocationTraceOutput + { + OrderId = orderId, + FromSnapshot = fromSnapshot, + Scheme = evaluation.StoredScheme, + SchemeName = ResolveAllocationSchemeName(evaluation.StoredScheme), + SchemeSource = evaluation.SchemeSource, + SchemeSourceName = ResolveAllocationSchemeSourceName(evaluation.SchemeSource), + ChargeableWeight = evaluation.ChargeableWeight, + ChargingStage = evaluation.ChargingStage, + ChargingStageName = ResolveChargingStageName(evaluation.ChargingStage), + SelectedChannelCode = selectedChannelCode ?? evaluation.Selected?.Channel.ChannelCode, + SelectedChannelName = evaluation.Selected?.Channel.ChannelName, + FailReason = evaluation.FailReason, + TraceTime = DateTime.Now, + Candidates = candidates, + Skipped = evaluation.Skipped + }; + } + + private static List SortAllocationCandidates( + string? scheme, + List candidates) + { + Func metric = scheme switch + { + AllocationSchemeCodes.TimeFastest => x => x.MinTime ?? int.MaxValue, + AllocationSchemeCodes.OverallBest => x => -x.Weight, + _ => x => x.TotalPayableCost + }; + + return candidates + .OrderByDescending(x => x.Selected) + .ThenBy(metric) + .ThenBy(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string ResolveAllocationSchemeName(string? scheme) => + scheme switch + { + AllocationSchemeCodes.TimeFastest => "时效优先", + AllocationSchemeCodes.OverallBest => "综合最优", + AllocationSchemeCodes.Designated => "指定渠道", + _ => "价格优先" + }; + + private static string ResolveAllocationSchemeSourceName(string? source) => + source switch + { + "reallocate" => "运营重新排货", + "designated" => "指定渠道", + _ => "产品默认" + }; + + private static string ResolveChargingStageName(string? stage) => + stage switch + { + OrderFeeChargingStageCodes.Inbound => "入库", + OrderFeeChargingStageCodes.Outbound => "出库", + OrderFeeChargingStageCodes.Forecast => "预报", + _ => stage ?? string.Empty + }; + private Task ConfirmServiceChannelAndPayableAsync( Order order, SalesProduct product, OrderBillingContext billing, - string chargingStage) => + string chargingStage, + string? schemeOverride = null, + int? forceServiceChannelId = null) => ConfirmServiceChannelAndPayableAsync( order, product, @@ -860,7 +1122,9 @@ public partial class OrderService billing.ZoneKeys, billing.ChargeableWeight, chargingStage, - billing.PriceTierKey); + billing.PriceTierKey, + schemeOverride, + forceServiceChannelId); /// /// 尝试排货并写入应付;失败时不抛异常,写入 供运营补配置。 @@ -869,11 +1133,19 @@ public partial class OrderService Order order, SalesProduct product, OrderBillingContext billing, - string chargingStage) + string chargingStage, + string? schemeOverride = null, + int? forceServiceChannelId = null) { try { - await ConfirmServiceChannelAndPayableAsync(order, product, billing, chargingStage); + await ConfirmServiceChannelAndPayableAsync( + order, + product, + billing, + chargingStage, + schemeOverride, + forceServiceChannelId); return true; } catch (BusinessException ex) @@ -1524,6 +1796,9 @@ public partial class OrderService OrderSource = orderSource, PickupTime = createContext?.PickupTime, CustomerSenderAddressId = createContext?.CustomerSenderAddressId, + InboundMethod = string.IsNullOrWhiteSpace(createContext?.InboundMethod) + ? null + : createContext!.InboundMethod!.Trim(), IsCod = input.IsCod ? (byte)1 : (byte)0, CodCurrency = input.CodCurrency, CodSum = input.CodSum, @@ -1767,7 +2042,8 @@ public partial class OrderService } /// - /// 入库收货后:仅覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并更新订单状态。 + /// 入库收货:同步只写入 receive 件重尺与收货时间,状态先置待排货; + /// 应收/应付/排货走异步任务,失败不回滚入库。 /// internal async Task ApplyReceiveBillingByWaybillAsync( string waybillNo, @@ -1789,20 +2065,101 @@ public partial class OrderService throw new BusinessException("未找到对应订单,请检查单号"); } - if (string.IsNullOrWhiteSpace(order.ProductCode)) + ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus); + + if (!InboundBillingInFlight.TryAdd(order.Id, 0)) { - throw new BusinessException("订单未绑定销售产品,无法入库计费"); + throw new BusinessException("订单入库处理中,请勿重复扫描"); } - ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus); + try + { + var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) + .OrderBy(x => x.Id) + .ToList(); + var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive); + if (receive == null) + { + throw new BusinessException("订单缺少收货件重尺记录"); + } + + ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value); + + // 尽力算计费重,失败留给异步计费重试,不阻断入库扫码。 + try + { + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product != null) + { + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); + } + } + } + catch + { + // ignore + } + + await OrderRepository.ReplaceWeightsAsync(order.Id, weights); + + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = InboundBillingProcessingReason; + order.ReceiveTime = DateTime.Now; + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode); + await EnqueueInboundBillingAsync(order); + } + finally + { + InboundBillingInFlight.TryRemove(order.Id, out _); + } + } + + /// + /// 入库异步计费:重算 inbound 应收 + 尝试排货写应付;失败仅落待排货原因。 + /// + internal async Task ProcessInboundBillingForBatchAsync( + int orderId, + CancellationToken cancellationToken = default) + { + _ = cancellationToken; + var order = await GetOrderOrThrowAsync(orderId); + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = "订单未绑定销售产品,无法入库计费"; + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + return; + } var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product == null) { - throw new BusinessException("订单关联的销售产品不存在"); + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = "订单关联的销售产品不存在"; + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + return; } - EnsureBillingProductActive(product); + try + { + EnsureBillingProductActive(product); + } + catch (BusinessException ex) + { + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = TrimPendingReason(ex.Message); + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + return; + } var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) .OrderBy(x => x.Id) @@ -1810,45 +2167,89 @@ public partial class OrderService var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive); if (receive == null) { - throw new BusinessException("订单缺少收货件重尺记录"); + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = "订单缺少收货件重尺记录"; + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + return; } - ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value); + ProductSnapshotBinding snapshotBinding; + try + { + snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); + await OrderRepository.ReplaceWeightsAsync(order.Id, weights); + } + catch (Exception ex) + { + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = TrimPendingReason( + ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}"); + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + return; + } - var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); - await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); - await OrderRepository.ReplaceWeightsAsync(order.Id, weights); - - var billing = await ResolveOrderBillingContextAsync( - order, - product, - receive.ChargeableWeight ?? 0, - snapshotBinding); - - // 入库重算应收:先清空全部应收,再按 inbound 阶段写入。 - await OrderRepository.DeleteReceivableFeesAsync(order.Id); - await UpsertReceivableFeesAsync( - order, - billing, - OrderFeeChargingStageCodes.Inbound); + OrderBillingContext billing; + try + { + billing = await ResolveOrderBillingContextAsync( + order, + product, + receive.ChargeableWeight ?? 0, + snapshotBinding); + } + catch (Exception ex) + { + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = TrimPendingReason( + ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}"); + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + return; + } + var (receivableOk, receivableFailReason) = await TryUpsertInboundReceivableFeesAsync(order, billing); var allocated = await TryConfirmServiceChannelAndPayableAsync( order, product, billing, OrderFeeChargingStageCodes.Inbound); - await RefreshMarketingPayableAmountAsync(order); + try + { + await RefreshMarketingPayableAmountAsync(order); + } + catch + { + // 营销金额刷新失败不影响入库结果 + } + + if (allocated && receivableOk) + { + order.OrderStatus = OrderStatusCodes.PendingPayment; + order.AllocationPendingReason = string.Empty; + } + else + { + order.OrderStatus = OrderStatusCodes.PendingAllocation; + // 排货成功会清空 AllocationPendingReason,需用应收失败原因回填 + if (!receivableOk && !string.IsNullOrWhiteSpace(receivableFailReason)) + { + order.AllocationPendingReason = receivableFailReason; + } + else if (string.IsNullOrWhiteSpace(order.AllocationPendingReason)) + { + order.AllocationPendingReason = allocated + ? "入库应收未匹配到有效卖价" + : "排货未匹配到可用服务渠道或买价"; + } + } - order.OrderStatus = allocated - ? OrderStatusCodes.PendingPayment - : OrderStatusCodes.PendingAllocation; - order.ReceiveTime = DateTime.Now; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - EnqueueLifecycleTrackAppend(order, SmallBagTrackLifecycleEvents.InboundTrackCode); - if (allocated) { var customer = order.CustomerId.HasValue @@ -1858,6 +2259,139 @@ public partial class OrderService } } + private async Task<(bool Ok, string? FailReason)> TryUpsertInboundReceivableFeesAsync( + Order order, + OrderBillingContext billing) + { + try + { + await OrderRepository.DeleteReceivableFeesAsync(order.Id); + await UpsertReceivableFeesAsync( + order, + billing, + OrderFeeChargingStageCodes.Inbound); + return (true, null); + } + catch (BusinessException ex) + { + return (false, TrimPendingReason(ex.Message)); + } + catch (Exception ex) + { + return (false, TrimPendingReason($"入库应收计费失败:{ex.Message}")); + } + } + + private static string TrimPendingReason(string? message) + { + var text = (message ?? string.Empty).Trim(); + if (text.Length <= 512) + { + return text; + } + + return text[..512]; + } + + private async Task EnqueueInboundBillingAsync(Order order) + { + var logger = LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.InboundBilling"); + try + { + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + // 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成) + try + { + await ProcessInboundBillingForBatchAsync(order.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "入库计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo); + order.OrderStatus = OrderStatusCodes.PendingAllocation; + order.AllocationPendingReason = TrimPendingReason($"入库计费失败:{ex.Message}"); + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + } + + return; + } + + var requestId = await ResolveInboundBillingRequestIdAsync(order.Id); + if (requestId == null) + { + logger.LogInformation( + "入库计费任务已在队列中,跳过重复投递 OrderId={OrderId} ServiceNo={ServiceNo}", + order.Id, + order.ServiceNo); + return; + } + + await BatchJobCoordinator.CreateAndEnqueueAsync( + new OrderBatchJobCreateInput + { + Action = OrderBatchActionCodes.InboundBilling, + OrderIds = [order.Id], + RequestId = requestId + }, + CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + } + catch (Exception ex) + { + // 投递失败不回滚入库;落待排货并记错误日志,供运营重跑入库计费 + logger.LogError( + ex, + "入库计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}", + order.Id, + order.ServiceNo); + try + { + order.OrderStatus = OrderStatusCodes.PendingAllocation; + if (string.IsNullOrWhiteSpace(order.AllocationPendingReason) + || order.AllocationPendingReason == InboundBillingProcessingReason) + { + order.AllocationPendingReason = InboundBillingEnqueueFailReason; + } + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + } + catch + { + // ignore + } + } + } + + /// + /// 幂等 RequestId:进行中任务复用并跳过;已结束任务换新 ID 允许重跑。 + /// 返回 null 表示已有进行中任务,无需再投递。 + /// + private async Task ResolveInboundBillingRequestIdAsync(int orderId) + { + var batchJobRepository = LazyServiceProvider.GetRequiredService(); + var stableId = $"inbound-billing:{orderId}"; + var existing = await batchJobRepository.GetByRequestIdAsync(stableId); + if (existing != null) + { + if (IsActiveBatchJob(existing.Status)) + { + return null; + } + + // 旧稳定 ID 已结束,后续用带时间戳 ID,避免被已完成任务挡住重跑 + return $"inbound-billing:{orderId}:{DateTime.UtcNow:yyyyMMddHHmmssfff}"; + } + + return stableId; + } + + private static bool IsActiveBatchJob(string? status) => + string.Equals(status, OrderBatchJobStatusCodes.Pending, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, OrderBatchJobStatusCodes.Running, StringComparison.OrdinalIgnoreCase); + /// /// 出库发货后:仅覆盖 ship 件重尺、重算计费重并更新状态;不再重算应收。 /// diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 01e3fb2..cecb10b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -32,6 +32,7 @@ public partial class OrderService : ApplicationService private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); + private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService(); private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); private LabelPdfGenerationService LabelPdfGenerationService => LazyServiceProvider.GetRequiredService(); @@ -405,7 +406,80 @@ public partial class OrderService : ApplicationService [HttpPost("/order/parcel/create")] public async Task CreateParcelAsync(OrderParcelCreateInput input) { - return await CreateParcelCoreAsync(input); + var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) + ? null + : input.InboundMethod.Trim(); + var createContext = inbound == null + ? null + : new ParcelCreateContext + { + InboundMethod = inbound, + PickupTime = string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase) + ? input.ExpectPickupTime + : null, + CustomerSenderAddressId = string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase) + ? input.SenderAddressId + : null + }; + + var output = await CreateParcelCoreAsync(input, createContext: createContext); + + if (string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase)) + { + await TryAttachPickupAfterParcelCreateAsync(input, output); + } + + return output; + } + + private async Task TryAttachPickupAfterParcelCreateAsync( + OrderParcelCreateInput input, + OrderParcelCreateOutput output) + { + if (output.OrderId <= 0) + { + return; + } + + if (input.SenderAddressId is not > 0) + { + output.PickupCreateError = "上门揽收需选择发件地址"; + return; + } + + if (input.ExpectPickupTime is null || input.ExpectPickupTime == default) + { + output.PickupCreateError = "上门揽收需填写期望揽收时间"; + return; + } + + if (input.WarehouseId is not > 0) + { + output.PickupCreateError = "上门揽收需选择仓库"; + return; + } + + try + { + var pickupService = LazyServiceProvider.GetRequiredService(); + var pickup = await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput + { + CustomerId = input.CustomerId, + SenderAddressId = input.SenderAddressId.Value, + WarehouseId = input.WarehouseId.Value, + CarrierCode = input.CarrierCode, + CarrierService = input.CarrierService, + ExpectPickupTime = input.ExpectPickupTime.Value, + OrderIds = [output.OrderId], + Remark = null + }); + output.PickupNo = pickup.PickupNo; + output.PickupOrderId = pickup.Id; + } + catch (Exception ex) + { + output.PickupCreateError = ex.Message; + } } /// diff --git a/src/Oms.Application/Services/Orders/PickupOrderService.cs b/src/Oms.Application/Services/Orders/PickupOrderService.cs new file mode 100644 index 0000000..1018de7 --- /dev/null +++ b/src/Oms.Application/Services/Orders/PickupOrderService.cs @@ -0,0 +1,867 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +/// +/// 揽收单(1 揽收 : N 物流)管理。 +/// +[ApiExplorerSettings(GroupName = "OMS-Order")] +public class PickupOrderService : ApplicationService +{ + private static readonly HashSet BindableOrderStatuses = new(StringComparer.OrdinalIgnoreCase) + { + OrderStatusCodes.Draft, + OrderStatusCodes.Predeclared, + OrderStatusCodes.PendingAllocation, + OrderStatusCodes.PendingPayment, + OrderStatusCodes.PendingShip, + OrderStatusCodes.PendingOnline, + }; + + private IPickupOrderRepository PickupOrderRepository => + LazyServiceProvider.GetRequiredService(); + + private IPickupOrderLineRepository PickupOrderLineRepository => + LazyServiceProvider.GetRequiredService(); + + private IOrderRepository OrderRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerRepository CustomerRepository => + LazyServiceProvider.GetRequiredService(); + + private ICustomerSenderAddressRepository CustomerSenderAddressRepository => + LazyServiceProvider.GetRequiredService(); + + private IWarehouseAddressRepository WarehouseAddressRepository => + LazyServiceProvider.GetRequiredService(); + + private ICountryRepository CountryRepository => + LazyServiceProvider.GetRequiredService(); + + /// + /// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。 + /// + [HttpPost("/order/pickup-order/create")] + public async Task CreateAsync([FromBody] PickupOrderCreateInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId); + CustomerContactHelper.EnsureContactComplete(customer); + + if (input.SenderAddressId <= 0) + { + throw new BusinessException("发件地址不能为空"); + } + + if (input.WarehouseId <= 0) + { + throw new BusinessException("仓库不能为空"); + } + + if (input.ExpectPickupTime == default) + { + throw new BusinessException("期望揽收时间不能为空"); + } + + var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList(); + if (orderIds.Count == 0) + { + throw new BusinessException("请至少选择一票物流订单"); + } + + var senderAddress = await CustomerSenderAddressRepository.GetOwnedAsync(input.SenderAddressId, customerId); + if (senderAddress == null) + { + throw new BusinessException("发件地址不存在"); + } + + var addressType = string.IsNullOrWhiteSpace(senderAddress.AddressType) + ? CustomerAddressTypeCodes.Sender + : senderAddress.AddressType; + if (!string.Equals(addressType, CustomerAddressTypeCodes.Sender, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("揽收单仅可选择发件地址"); + } + + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId); + if (warehouse == null) + { + throw new BusinessException("仓库不存在"); + } + + var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); + var orderMap = orders.ToDictionary(x => x.Id); + var existingLines = await PickupOrderLineRepository.GetActiveByOrderIdsAsync(orderIds); + var existingByOrderId = existingLines.ToDictionary(x => x.OrderId); + + var rejected = new List(); + var accepted = new List(); + foreach (var orderId in orderIds) + { + if (!orderMap.TryGetValue(orderId, out var order)) + { + rejected.Add(new PickupOrderRejectItem { OrderId = orderId, Reason = "订单不存在" }); + continue; + } + + var reason = ValidateOrderForBind( + order, + customerId, + input.WarehouseId, + input.SenderAddressId, + existingByOrderId.GetValueOrDefault(orderId)); + if (reason != null) + { + rejected.Add(new PickupOrderRejectItem + { + OrderId = orderId, + ServiceNo = order.ServiceNo, + Reason = reason + }); + continue; + } + + accepted.Add(order); + } + + if (rejected.Count > 0) + { + throw new BusinessException( + "部分运单不能合并揽收:" + + string.Join(";", rejected.Select(x => $"{x.ServiceNo ?? x.OrderId.ToString()} — {x.Reason}"))); + } + + if (accepted.Count == 0) + { + throw new BusinessException("没有可挂载的物流订单"); + } + + string? senderCountryCode = null; + if (senderAddress.Country.HasValue) + { + var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == senderAddress.Country.Value); + senderCountryCode = country?.CountryCode; + } + + var weights = await OrderRepository.GetWeightsByOrderIdsAsync(accepted.Select(x => x.Id)); + var weightSum = weights + .GroupBy(x => x.OrderId) + .Select(g => g.OrderByDescending(w => w.Type == OrderWeightTypeCodes.Receive ? 2 : 1).FirstOrDefault()) + .Where(x => x != null) + .Sum(x => x!.ActualWeight ?? 0); + + var freight = input.FreightAmount; + var receivable = freight; + + var entity = new PickupOrder + { + PickupNo = await GeneratePickupNoAsync(), + CustomerId = customerId, + SenderAddressId = senderAddress.Id, + SenderName = senderAddress.SenderName ?? string.Empty, + SenderPhone = senderAddress.Phone, + SenderCompany = senderAddress.Company, + SenderCountryCode = senderCountryCode, + SenderProvince = senderAddress.Province, + SenderCity = senderAddress.City, + SenderZipCode = senderAddress.ZipCode, + SenderStreetAddress1 = senderAddress.StreetAddress1 ?? string.Empty, + SenderStreetAddress2 = senderAddress.StreetAddress2, + WarehouseId = input.WarehouseId, + CarrierCode = NormalizeOptional(input.CarrierCode), + CarrierService = NormalizeOptional(input.CarrierService), + ExpectPickupTime = input.ExpectPickupTime, + Status = PickupOrderStatusCodes.Draft, + ParcelCount = accepted.Count, + TotalActualWeight = weightSum > 0 ? weightSum : null, + Currency = "CNY", + FreightAmount = freight, + OtherFeeAmount = null, + ReceivableAmount = receivable, + PayStatus = PickupPayStatusCodes.Unpaid, + PayMode = PickupPayModeCodes.Prepaid, + SettlementStatus = PickupSettlementStatusCodes.None, + Remark = NormalizeOptional(input.Remark) + }; + + await PickupOrderRepository.InsertAsync(entity); + + var sort = 0; + foreach (var order in accepted) + { + await PickupOrderLineRepository.InsertAsync(new PickupOrderLine + { + PickupOrderId = entity.Id, + OrderId = order.Id, + SortNo = sort++ + }); + + order.PickupOrderId = entity.Id; + order.InboundMethod = OrderInboundMethodCodes.DoorPickup; + order.PickupTime = entity.ExpectPickupTime; + order.CustomerSenderAddressId = entity.SenderAddressId; + await OrderRepository.UpdateAsync(order); + } + + return await MapOutputAsync(entity, includeOrders: true); + } + + /// + /// 追加物流单到已有揽收单。 + /// + [HttpPost("/order/pickup-order/add-orders")] + public async Task AddOrdersAsync([FromBody] PickupOrderOrdersInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (!PickupOrderStatusCodes.IsMutableBinding(pickup.Status)) + { + throw new BusinessException("当前揽收单状态不可追加运单"); + } + + var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList(); + if (orderIds.Count == 0) + { + throw new BusinessException("请至少选择一票物流订单"); + } + + var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); + var orderMap = orders.ToDictionary(x => x.Id); + var existingLines = await PickupOrderLineRepository.GetActiveByOrderIdsAsync(orderIds); + var existingByOrderId = existingLines.ToDictionary(x => x.OrderId); + var currentLines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + var sort = currentLines.Count == 0 ? 0 : currentLines.Max(x => x.SortNo) + 1; + + var rejected = new List(); + var accepted = new List(); + foreach (var orderId in orderIds) + { + if (!orderMap.TryGetValue(orderId, out var order)) + { + rejected.Add(new PickupOrderRejectItem { OrderId = orderId, Reason = "订单不存在" }); + continue; + } + + var reason = ValidateOrderForBind( + order, + pickup.CustomerId, + pickup.WarehouseId, + pickup.SenderAddressId, + existingByOrderId.GetValueOrDefault(orderId)); + if (reason != null) + { + rejected.Add(new PickupOrderRejectItem + { + OrderId = orderId, + ServiceNo = order.ServiceNo, + Reason = reason + }); + continue; + } + + accepted.Add(order); + } + + if (rejected.Count > 0) + { + throw new BusinessException( + "部分运单不能追加:" + + string.Join(";", rejected.Select(x => $"{x.ServiceNo ?? x.OrderId.ToString()} — {x.Reason}"))); + } + + foreach (var order in accepted) + { + await PickupOrderLineRepository.InsertAsync(new PickupOrderLine + { + PickupOrderId = pickup.Id, + OrderId = order.Id, + SortNo = sort++ + }); + order.PickupOrderId = pickup.Id; + order.InboundMethod = OrderInboundMethodCodes.DoorPickup; + order.PickupTime = pickup.ExpectPickupTime; + order.CustomerSenderAddressId = pickup.SenderAddressId; + await OrderRepository.UpdateAsync(order); + } + + await RefreshParcelCountAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 从揽收单移除物流单;移除后若无关联则自动取消揽收单。 + /// + [HttpPost("/order/pickup-order/remove-orders")] + public async Task RemoveOrdersAsync([FromBody] PickupOrderOrdersInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (!PickupOrderStatusCodes.IsMutableBinding(pickup.Status)) + { + throw new BusinessException("当前揽收单状态不可移除运单"); + } + + var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToHashSet(); + if (orderIds.Count == 0) + { + throw new BusinessException("请至少选择一票物流订单"); + } + + var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + var toRemove = lines.Where(x => orderIds.Contains(x.OrderId)).ToList(); + if (toRemove.Count == 0) + { + throw new BusinessException("所选运单不在该揽收单中"); + } + + foreach (var line in toRemove) + { + await PickupOrderLineRepository.HardDeleteAsync(line); + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == line.OrderId); + if (order != null && order.PickupOrderId == pickup.Id) + { + order.PickupOrderId = null; + await OrderRepository.UpdateAsync(order); + } + } + + var remain = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + if (remain.Count == 0) + { + if (pickup.PayStatus == PickupPayStatusCodes.Paid) + { + throw new BusinessException("揽收单已支付且将无关联运单,请先处理退款后再取消(P1 请联系客服)"); + } + + pickup.Status = PickupOrderStatusCodes.Cancelled; + pickup.ParcelCount = 0; + await PickupOrderRepository.UpdateAsync(pickup); + } + else + { + await RefreshParcelCountAsync(pickup); + } + + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 揽收单分页列表。 + /// + [HttpPost("/order/pickup-order/list")] + public async Task> ListAsync([FromBody] PickupOrderListInput input) + { + var page = input.Page <= 0 ? 1 : input.Page; + var pageSize = input.PageSize <= 0 ? 30 : Math.Min(input.PageSize, 200); + var customerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + + var keyword = NormalizeOptional(input.Keyword); + var status = NormalizeOptional(input.Status); + var payStatus = NormalizeOptional(input.PayStatus); + + var all = await PickupOrderRepository.GetListAsync(x => + (customerId == null || x.CustomerId == customerId.Value) && + (status == null || x.Status == status) && + (payStatus == null || x.PayStatus == payStatus) && + (input.CreateTimeStart == null || x.CreateTime >= input.CreateTimeStart) && + (input.CreateTimeEnd == null || x.CreateTime <= input.CreateTimeEnd) && + (keyword == null || x.PickupNo.Contains(keyword) || + (x.CarrierWaybillNo != null && x.CarrierWaybillNo.Contains(keyword)) || + (x.SenderName != null && x.SenderName.Contains(keyword)))); + + var ordered = all.OrderByDescending(x => x.CreateTime).ThenByDescending(x => x.Id).ToList(); + var total = ordered.Count; + var pageItems = ordered.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + var outputs = new List(); + foreach (var item in pageItems) + { + outputs.Add(await MapOutputAsync(item, includeOrders: false)); + } + + return new OmsPageResult { Items = outputs, Total = total }; + } + + /// + /// 揽收单详情。 + /// + [HttpGet("/order/pickup-order/detail")] + public async Task GetDetailAsync([FromQuery] int id) + { + var pickup = await GetOwnedPickupOrThrowAsync(id); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 确认对客揽收运费。 + /// + [HttpPost("/order/pickup-order/confirm-freight")] + public async Task ConfirmFreightAsync([FromBody] PickupOrderConfirmFreightInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (pickup.Status is PickupOrderStatusCodes.Cancelled or PickupOrderStatusCodes.Failed) + { + throw new BusinessException("已取消/失败的揽收单不可确认运费"); + } + + if (pickup.PayStatus == PickupPayStatusCodes.Paid) + { + throw new BusinessException("已支付不可修改运费"); + } + + if (input.FreightAmount < 0) + { + throw new BusinessException("运费不能为负数"); + } + + var other = input.OtherFeeAmount ?? 0; + if (other < 0) + { + throw new BusinessException("其他费用不能为负数"); + } + + pickup.FreightAmount = input.FreightAmount; + pickup.OtherFeeAmount = other; + pickup.ReceivableAmount = input.FreightAmount + other; + if (input.CarrierPayableAmount.HasValue) + { + pickup.CarrierPayableAmount = input.CarrierPayableAmount; + } + + await PickupOrderRepository.UpdateAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 客户支付揽收运费(余额)。 + /// + [HttpPost("/order/pickup-order/pay")] + public async Task PayAsync([FromBody] PickupOrderIdInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (pickup.PayStatus == PickupPayStatusCodes.Paid) + { + throw new BusinessException("揽收单已支付"); + } + + if (pickup.Status is PickupOrderStatusCodes.Cancelled or PickupOrderStatusCodes.Failed) + { + throw new BusinessException("当前状态不可支付"); + } + + var amount = pickup.ReceivableAmount ?? pickup.FreightAmount ?? 0; + if (amount <= 0) + { + throw new BusinessException("请先确认揽收运费"); + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == pickup.CustomerId); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + var balanceMutator = LazyServiceProvider.GetRequiredService(); + await balanceMutator.DebitPickupPayAsync( + customer, + amount, + pickup.Id, + pickup.PickupNo, + $"揽收支付 {pickup.PickupNo}"); + + pickup.PayStatus = PickupPayStatusCodes.Paid; + pickup.PayTime = DateTime.Now; + await PickupOrderRepository.UpdateAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 补承运商并预约(draft → scheduled)。 + /// + [HttpPost("/order/pickup-order/schedule")] + public async Task ScheduleAsync([FromBody] PickupOrderScheduleInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (pickup.Status is not (PickupOrderStatusCodes.Draft or PickupOrderStatusCodes.Scheduled)) + { + throw new BusinessException("当前状态不可预约下发"); + } + + var carrier = NormalizeOptional(input.CarrierCode); + if (carrier == null) + { + throw new BusinessException("承运商不能为空"); + } + + if (string.Equals(pickup.PayMode, PickupPayModeCodes.Prepaid, StringComparison.OrdinalIgnoreCase) + && pickup.PayStatus != PickupPayStatusCodes.Paid) + { + throw new BusinessException("请先支付揽收运费"); + } + + var amount = pickup.ReceivableAmount ?? pickup.FreightAmount ?? 0; + if (amount <= 0) + { + throw new BusinessException("请先确认揽收运费"); + } + + pickup.CarrierCode = carrier; + pickup.CarrierService = NormalizeOptional(input.CarrierService); + if (input.ExpectPickupTime.HasValue && input.ExpectPickupTime.Value != default) + { + pickup.ExpectPickupTime = input.ExpectPickupTime.Value; + } + + pickup.Status = PickupOrderStatusCodes.Scheduled; + await PickupOrderRepository.UpdateAsync(pickup); + + var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + foreach (var line in lines) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == line.OrderId); + if (order == null) + { + continue; + } + + order.PickupTime = pickup.ExpectPickupTime; + await OrderRepository.UpdateAsync(order); + } + + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 改约(未取件)。 + /// + [HttpPost("/order/pickup-order/reschedule")] + public async Task RescheduleAsync([FromBody] PickupOrderRescheduleInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (!PickupOrderStatusCodes.IsMutableBinding(pickup.Status)) + { + throw new BusinessException("当前状态不可改约"); + } + + if (input.ExpectPickupTime == default) + { + throw new BusinessException("期望揽收时间不能为空"); + } + + pickup.ExpectPickupTime = input.ExpectPickupTime; + await PickupOrderRepository.UpdateAsync(pickup); + + var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + foreach (var line in lines) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == line.OrderId); + if (order == null) + { + continue; + } + + order.PickupTime = pickup.ExpectPickupTime; + await OrderRepository.UpdateAsync(order); + } + + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 取消揽收单。 + /// + [HttpPost("/order/pickup-order/cancel")] + public async Task CancelAsync([FromBody] PickupOrderIdInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (!PickupOrderStatusCodes.IsMutableBinding(pickup.Status)) + { + throw new BusinessException("当前状态不可取消"); + } + + if (pickup.PayStatus == PickupPayStatusCodes.Paid) + { + throw new BusinessException("已支付揽收单取消请先处理退款(P1 人工)"); + } + + var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + foreach (var line in lines) + { + await PickupOrderLineRepository.HardDeleteAsync(line); + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == line.OrderId); + if (order != null && order.PickupOrderId == pickup.Id) + { + order.PickupOrderId = null; + await OrderRepository.UpdateAsync(order); + } + } + + pickup.Status = PickupOrderStatusCodes.Cancelled; + pickup.ParcelCount = 0; + await PickupOrderRepository.UpdateAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 标记已取件。 + /// + [HttpPost("/order/pickup-order/mark-picked")] + public async Task MarkPickedAsync([FromBody] PickupOrderIdInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (pickup.Status is not (PickupOrderStatusCodes.Scheduled or PickupOrderStatusCodes.Dispatched)) + { + throw new BusinessException("仅已预约/已派车状态可标记取件"); + } + + pickup.Status = PickupOrderStatusCodes.Picked; + pickup.ActualPickupTime = DateTime.Now; + if (pickup.SettlementStatus == PickupSettlementStatusCodes.None) + { + pickup.SettlementStatus = PickupSettlementStatusCodes.Pending; + } + + await PickupOrderRepository.UpdateAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 标记已到仓。 + /// + [HttpPost("/order/pickup-order/mark-inbound")] + public async Task MarkInboundAsync([FromBody] PickupOrderIdInput input) + { + var pickup = await GetOwnedPickupOrThrowAsync(input.Id); + if (pickup.Status != PickupOrderStatusCodes.Picked) + { + throw new BusinessException("仅已取件状态可确认到仓"); + } + + pickup.Status = PickupOrderStatusCodes.Inbound; + await PickupOrderRepository.UpdateAsync(pickup); + return await MapOutputAsync(pickup, includeOrders: true); + } + + /// + /// 供小包创建成功后内部挂载(失败由调用方吞掉并回显)。 + /// + public async Task CreateForParcelInternalAsync(PickupOrderCreateInput input) + { + return await CreateAsync(input); + } + + private async Task GetOwnedPickupOrThrowAsync(int id) + { + if (id <= 0) + { + throw new BusinessException("揽收单Id无效"); + } + + var pickup = await PickupOrderRepository.FirstOrDefaultAsync(x => x.Id == id); + if (pickup == null) + { + throw new BusinessException("揽收单不存在"); + } + + if (OmsSessionHelper.IsCustomerLogin(CurrentUser)) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + null); + if (pickup.CustomerId != customerId) + { + throw new BusinessException("揽收单不存在"); + } + } + + return pickup; + } + + private static string? ValidateOrderForBind( + Order order, + int customerId, + int warehouseId, + int senderAddressId, + PickupOrderLine? existingLine) + { + if (order.CustomerId != customerId) + { + return "不属于当前客户"; + } + + if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return "订单已删除"; + } + + if (!BindableOrderStatuses.Contains(order.OrderStatus ?? string.Empty)) + { + return $"订单状态不可挂载揽收({order.OrderStatus})"; + } + + if (order.WarehouseId != warehouseId) + { + return "仓库不一致"; + } + + if (existingLine != null) + { + return existingLine.PickupOrderId == order.PickupOrderId + ? "已在当前揽收单中" + : $"已绑定其他揽收单(Id={existingLine.PickupOrderId})"; + } + + if (order.PickupOrderId is > 0) + { + return $"已绑定其他揽收单(Id={order.PickupOrderId})"; + } + + // 已有发件地址快照时要求与揽收同址;尚未绑定时以本次选择为准 + if (order.CustomerSenderAddressId is > 0 && order.CustomerSenderAddressId != senderAddressId) + { + return "取件地址不一致"; + } + + return null; + } + + private async Task RefreshParcelCountAsync(PickupOrder pickup) + { + var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(pickup.Id); + pickup.ParcelCount = lines.Count; + await PickupOrderRepository.UpdateAsync(pickup); + } + + private async Task GeneratePickupNoAsync() + { + for (var i = 0; i < 8; i++) + { + var no = $"PK{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}"; + var existed = await PickupOrderRepository.GetByPickupNoAsync(no); + if (existed == null) + { + return no; + } + } + + throw new BusinessException("揽收单号生成失败"); + } + + private async Task MapOutputAsync(PickupOrder entity, bool includeOrders) + { + string? warehouseName = null; + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == entity.WarehouseId); + if (warehouse != null) + { + warehouseName = warehouse.WarehouseName; + } + + string? customerName = null; + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == entity.CustomerId); + if (customer != null) + { + customerName = customer.CustomerName; + } + + var output = new PickupOrderOutput + { + Id = entity.Id, + PickupNo = entity.PickupNo, + CustomerId = entity.CustomerId, + CustomerName = customerName, + SenderAddressId = entity.SenderAddressId, + SenderName = entity.SenderName, + SenderPhone = entity.SenderPhone, + SenderCompany = entity.SenderCompany, + SenderProvince = entity.SenderProvince, + SenderCity = entity.SenderCity, + SenderStreetAddress1 = entity.SenderStreetAddress1, + WarehouseId = entity.WarehouseId, + WarehouseName = warehouseName, + CarrierCode = entity.CarrierCode, + CarrierService = entity.CarrierService, + CarrierWaybillNo = entity.CarrierWaybillNo, + ExpectPickupTime = entity.ExpectPickupTime, + ActualPickupTime = entity.ActualPickupTime, + Status = entity.Status, + ParcelCount = entity.ParcelCount, + TotalActualWeight = entity.TotalActualWeight, + ChargeableWeight = entity.ChargeableWeight, + Currency = entity.Currency, + FreightAmount = entity.FreightAmount, + OtherFeeAmount = entity.OtherFeeAmount, + ReceivableAmount = entity.ReceivableAmount, + PayStatus = entity.PayStatus, + PayTime = entity.PayTime, + PayMode = entity.PayMode, + CarrierPayableAmount = entity.CarrierPayableAmount, + SettlementStatus = entity.SettlementStatus, + SettlementBatchNo = entity.SettlementBatchNo, + Remark = entity.Remark, + CreateTime = entity.CreateTime + }; + + if (!includeOrders) + { + return output; + } + + var lines = await PickupOrderLineRepository.GetByPickupOrderIdAsync(entity.Id); + if (lines.Count == 0) + { + return output; + } + + var orderIds = lines.Select(x => x.OrderId).ToList(); + var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id)); + var orderMap = orders.ToDictionary(x => x.Id); + var weights = await OrderRepository.GetWeightsByOrderIdsAsync(orderIds); + var weightMap = weights + .GroupBy(x => x.OrderId) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(w => w.Type == OrderWeightTypeCodes.Receive ? 2 : 1) + .Select(w => w.ChargeableWeight ?? w.ActualWeight) + .FirstOrDefault()); + + output.Orders = lines + .OrderBy(x => x.SortNo) + .Select(line => + { + orderMap.TryGetValue(line.OrderId, out var order); + weightMap.TryGetValue(line.OrderId, out var cw); + return new PickupOrderLineOutput + { + OrderId = line.OrderId, + ServiceNo = order?.ServiceNo, + ClientNo = order?.ClientNo, + OrderStatus = order?.OrderStatus, + WarehouseId = order?.WarehouseId, + ChargeableWeight = cw + }; + }) + .ToList(); + + return output; + } + + private static string? NormalizeOptional(string? value) + { + var v = value?.Trim(); + return string.IsNullOrEmpty(v) ? null : v; + } +} diff --git a/src/Oms.Application/Shared/CustomerBalanceMutator.cs b/src/Oms.Application/Shared/CustomerBalanceMutator.cs index 0e7c839..a2ded58 100644 --- a/src/Oms.Application/Shared/CustomerBalanceMutator.cs +++ b/src/Oms.Application/Shared/CustomerBalanceMutator.cs @@ -63,6 +63,29 @@ public class CustomerBalanceMutator : IScopedDependency cancellationToken); } + public Task DebitPickupPayAsync( + Customer customer, + decimal amount, + int pickupOrderId, + string pickupNo, + string? remark, + CancellationToken cancellationToken = default) + { + if (amount <= 0) + { + throw new BusinessException("支付金额必须大于 0"); + } + + return ApplyAsync( + customer, + -amount, + CustomerAccountFlowBizTypes.PickupPay, + pickupOrderId, + pickupNo, + remark, + cancellationToken); + } + private async Task ApplyAsync( Customer customer, decimal signedAmount, diff --git a/src/Oms.Domain/ChannelsAggregate/Consts/AllocationSchemeCodes.cs b/src/Oms.Domain/ChannelsAggregate/Consts/AllocationSchemeCodes.cs index d149a97..c61c4cd 100644 --- a/src/Oms.Domain/ChannelsAggregate/Consts/AllocationSchemeCodes.cs +++ b/src/Oms.Domain/ChannelsAggregate/Consts/AllocationSchemeCodes.cs @@ -5,4 +5,15 @@ public static class AllocationSchemeCodes public const string CostLowest = "costLowest"; public const string TimeFastest = "timeFastest"; public const string OverallBest = "overallBest"; + + /// 运营指定渠道(仅重新排货场景,不写入销售产品默认方案)。 + public const string Designated = "designated"; + + public static bool IsAutoScheme(string? scheme) => + string.Equals(scheme, CostLowest, StringComparison.OrdinalIgnoreCase) + || string.Equals(scheme, TimeFastest, StringComparison.OrdinalIgnoreCase) + || string.Equals(scheme, OverallBest, StringComparison.OrdinalIgnoreCase); + + public static bool IsDesignated(string? scheme) => + string.Equals(scheme, Designated, StringComparison.OrdinalIgnoreCase); } diff --git a/src/Oms.Domain/CustomersAggregate/Consts/CustomerAccountFlowBizTypes.cs b/src/Oms.Domain/CustomersAggregate/Consts/CustomerAccountFlowBizTypes.cs index 0c62c14..e77fc24 100644 --- a/src/Oms.Domain/CustomersAggregate/Consts/CustomerAccountFlowBizTypes.cs +++ b/src/Oms.Domain/CustomersAggregate/Consts/CustomerAccountFlowBizTypes.cs @@ -9,6 +9,8 @@ public static class CustomerAccountFlowBizTypes public const string OrderPay = "orderPay"; + public const string PickupPay = "pickupPay"; + public const string ReferralCommission = "referralCommission"; public const string CommissionToBalance = "commissionToBalance"; diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs index ec1ac4c..da96b64 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs @@ -32,6 +32,8 @@ public static class OrderBatchActionCodes public const string HandleStatusUpdate = "handle-status-update"; public const string Print = "print"; public const string GenerateLabelPdf = "generate-label-pdf"; + public const string InboundBilling = "inbound-billing"; + public const string Reallocate = "reallocate"; public const string Export = "export"; public const string ParcelCreate = "parcel-create"; public const string ImportRow = "import-row"; diff --git a/src/Oms.Domain/OrdersAggregate/Consts/PickupOrderStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/PickupOrderStatusCodes.cs new file mode 100644 index 0000000..7bf5b67 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/PickupOrderStatusCodes.cs @@ -0,0 +1,43 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +public static class PickupOrderStatusCodes +{ + public const string Draft = "draft"; + public const string Scheduled = "scheduled"; + public const string Dispatched = "dispatched"; + public const string Picked = "picked"; + public const string Inbound = "inbound"; + public const string Cancelled = "cancelled"; + public const string Failed = "failed"; + + public static bool IsMutableBinding(string? status) => + status is Draft or Scheduled; + + public static bool IsTerminal(string? status) => + status is Cancelled or Failed or Inbound; +} + +public static class PickupPayStatusCodes +{ + public const string Unpaid = "unpaid"; + public const string Paid = "paid"; +} + +public static class PickupSettlementStatusCodes +{ + public const string None = "none"; + public const string Pending = "pending"; + public const string Settled = "settled"; +} + +public static class PickupPayModeCodes +{ + public const string Prepaid = "prepaid"; + public const string Collect = "collect"; +} + +public static class OrderInboundMethodCodes +{ + public const string SelfShip = "selfShip"; + public const string DoorPickup = "doorPickup"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index 2625a7a..78aaf3e 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -60,6 +60,24 @@ public class Order : OmsAuditedAggregateRoot [SugarColumn(Length = 512, IsNullable = true)] public string? AllocationPendingReason { get; set; } + /// + /// 最近一次排货方案:costLowest / timeFastest / overallBest / designated。 + /// + [SugarColumn(Length = 32, IsNullable = true)] + public string? AllocationScheme { get; set; } + + /// + /// 最近一次排货过程快照 JSON。 + /// + [SugarColumn(ColumnDataType = "json", IsNullable = true)] + public string? AllocationTraceJson { get; set; } + + /// + /// 排货快照写入时间。 + /// + [SugarColumn(IsNullable = true)] + public DateTime? AllocationTraceTime { get; set; } + public string? SalesProductRulesVersion { get; set; } public string? SalesProductPriceVersion { get; set; } @@ -138,6 +156,18 @@ public class Order : OmsAuditedAggregateRoot /// public int? CustomerSenderAddressId { get; set; } + /// + /// 到仓方式:selfShip / doorPickup。 + /// + [SugarColumn(Length = 16, IsNullable = true)] + public string? InboundMethod { get; set; } + + /// + /// 关联揽收单 Id(冗余,真源为 t_pickup_order_line)。 + /// + [SugarColumn(IsNullable = true)] + public int? PickupOrderId { get; set; } + public string? OrganizationId { get; set; } public int SecretLevel { get; set; } = 2; diff --git a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs new file mode 100644 index 0000000..a65c6ab --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs @@ -0,0 +1,114 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +/// +/// 揽收单(上门取件履约 + 对客收款 + 承运结算)。 +/// +[SugarTable("t_pickup_order")] +public class PickupOrder : OmsAuditedAggregateRoot +{ + [SugarColumn(Length = 32)] + public string PickupNo { get; set; } = string.Empty; + + public int CustomerId { get; set; } + + public int SenderAddressId { get; set; } + + [SugarColumn(Length = 50)] + public string SenderName { get; set; } = string.Empty; + + [SugarColumn(Length = 32, IsNullable = true)] + public string? SenderPhone { get; set; } + + [SugarColumn(Length = 255, IsNullable = true)] + public string? SenderCompany { get; set; } + + [SugarColumn(Length = 8, IsNullable = true)] + public string? SenderCountryCode { get; set; } + + [SugarColumn(Length = 50, IsNullable = true)] + public string? SenderProvince { get; set; } + + [SugarColumn(Length = 50, IsNullable = true)] + public string? SenderCity { get; set; } + + [SugarColumn(Length = 50, IsNullable = true)] + public string? SenderZipCode { get; set; } + + [SugarColumn(Length = 500)] + public string SenderStreetAddress1 { get; set; } = string.Empty; + + [SugarColumn(Length = 500, IsNullable = true)] + public string? SenderStreetAddress2 { get; set; } + + public int WarehouseId { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? CarrierCode { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? CarrierService { get; set; } + + [SugarColumn(Length = 64, IsNullable = true)] + public string? CarrierWaybillNo { get; set; } + + public DateTime ExpectPickupTime { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? PickupWindowStart { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? PickupWindowEnd { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? ActualPickupTime { get; set; } + + [SugarColumn(Length = 32)] + public string Status { get; set; } = "draft"; + + public int ParcelCount { get; set; } + + [SugarColumn(DecimalDigits = 3, IsNullable = true)] + public decimal? TotalActualWeight { get; set; } + + [SugarColumn(DecimalDigits = 3, IsNullable = true)] + public decimal? ChargeableWeight { get; set; } + + [SugarColumn(Length = 8)] + public string Currency { get; set; } = "CNY"; + + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? FreightAmount { get; set; } + + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? OtherFeeAmount { get; set; } + + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? ReceivableAmount { get; set; } + + [SugarColumn(Length = 16)] + public string PayStatus { get; set; } = "unpaid"; + + [SugarColumn(IsNullable = true)] + public DateTime? PayTime { get; set; } + + [SugarColumn(Length = 16)] + public string PayMode { get; set; } = "prepaid"; + + [SugarColumn(DecimalDigits = 2, IsNullable = true)] + public decimal? CarrierPayableAmount { get; set; } + + [SugarColumn(Length = 16)] + public string SettlementStatus { get; set; } = "none"; + + [SugarColumn(Length = 64, IsNullable = true)] + public string? SettlementBatchNo { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? SettledAt { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/PickupOrderLine.cs b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrderLine.cs new file mode 100644 index 0000000..3c06b86 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/PickupOrderLine.cs @@ -0,0 +1,17 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +/// +/// 揽收单关联物流订单。 +/// +[SugarTable("t_pickup_order_line")] +public class PickupOrderLine : OmsAuditedAggregateRoot +{ + public int PickupOrderId { get; set; } + + public int OrderId { get; set; } + + public int SortNo { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs new file mode 100644 index 0000000..143c38d --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IPickupOrderRepository.cs @@ -0,0 +1,24 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface IPickupOrderRepository : IRepository +{ + Task GetByPickupNoAsync(string pickupNo, CancellationToken cancellationToken = default); + + Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default); +} + +public interface IPickupOrderLineRepository : IRepository +{ + Task> GetByPickupOrderIdAsync(int pickupOrderId, CancellationToken cancellationToken = default); + + Task GetActiveByOrderIdAsync(int orderId, CancellationToken cancellationToken = default); + + Task> GetActiveByOrderIdsAsync(IReadOnlyCollection orderIds, CancellationToken cancellationToken = default); + + Task HardDeleteByPickupOrderIdAsync(int pickupOrderId, CancellationToken cancellationToken = default); + + Task HardDeleteAsync(PickupOrderLine line, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs b/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs new file mode 100644 index 0000000..9e10219 --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs @@ -0,0 +1,62 @@ +using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Orders; + +public class PickupOrderRepository : OmsSqlSugarRepository, IPickupOrderRepository, IScopedDependency +{ + public Task GetByPickupNoAsync(string pickupNo, CancellationToken cancellationToken = default) + { + var no = pickupNo.Trim(); + return FirstOrDefaultAsync(x => x.PickupNo == no, cancellationToken); + } + + public Task GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken); + } +} + +public class PickupOrderLineRepository : OmsSqlSugarRepository, IPickupOrderLineRepository, IScopedDependency +{ + public Task> GetByPickupOrderIdAsync(int pickupOrderId, CancellationToken cancellationToken = default) + { + return GetListAsync(x => x.PickupOrderId == pickupOrderId, cancellationToken: cancellationToken); + } + + public Task GetActiveByOrderIdAsync(int orderId, CancellationToken cancellationToken = default) + { + return FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken); + } + + public async Task> GetActiveByOrderIdsAsync( + IReadOnlyCollection orderIds, + CancellationToken cancellationToken = default) + { + if (orderIds == null || orderIds.Count == 0) + { + return []; + } + + var ids = orderIds.Distinct().ToList(); + return await GetListAsync(x => ids.Contains(x.OrderId), cancellationToken: cancellationToken); + } + + public async Task HardDeleteByPickupOrderIdAsync(int pickupOrderId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable() + .Where(x => x.PickupOrderId == pickupOrderId) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task HardDeleteAsync(PickupOrderLine line, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable() + .Where(x => x.Id == line.Id) + .ExecuteCommandAsync(cancellationToken); + } +}