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);
+ }
+}