feat: add pickup-order model and door-pickup linking

Support 1 pickup to N logistics orders with create/pay/schedule APIs, parcel inboundMethod auto-attach, plus allocation-trace and inbound-billing updates already in tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-08-03 12:22:30 +08:00
parent cc100d40cf
commit c02cb21049
33 changed files with 2809 additions and 79 deletions

View File

@ -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`;

View File

@ -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`);

View File

@ -59,4 +59,9 @@ public class InboundOrderLookupOutput
/// 订单状态名称。
/// </summary>
public string? OrderStatusName { get; set; }
/// <summary>
/// 待排货/计费失败原因。
/// </summary>
public string? AllocationPendingReason { get; set; }
}

View File

@ -75,6 +75,26 @@ public class InboundRecordOutput
/// </summary>
public string? OperatorName { get; set; }
/// <summary>
/// 关联订单主键。
/// </summary>
public int? OrderId { get; set; }
/// <summary>
/// 订单状态码。
/// </summary>
public string? OrderStatus { get; set; }
/// <summary>
/// 订单状态名称。
/// </summary>
public string? OrderStatusName { get; set; }
/// <summary>
/// 待排货/计费失败原因(有则展示)。
/// </summary>
public string? AllocationPendingReason { get; set; }
/// <summary>
/// 创建时间。
/// </summary>

View File

@ -0,0 +1,81 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 订单排货过程(快照或现算)。
/// </summary>
public class OrderAllocationTraceOutput
{
public int OrderId { get; set; }
/// <summary>true=历史快照;false=按当前规则现算。</summary>
public bool FromSnapshot { get; set; }
public string? Scheme { get; set; }
public string? SchemeName { get; set; }
/// <summary>productDefault / reallocate / designated</summary>
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<OrderAllocationCandidateOutput> Candidates { get; set; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; set; } = [];
}
public class OrderAllocationCandidateOutput
{
public int ServiceChannelId { get; set; }
public string ChannelCode { get; set; } = string.Empty;
public string? ChannelName { get; set; }
/// <summary>应付合计(价格优先主指标)。</summary>
public decimal TotalPayableCost { get; set; }
/// <summary>时效天数(时效优先主指标)。</summary>
public int? MinTime { get; set; }
/// <summary>综合权重(综合最优主指标)。</summary>
public int Weight { get; set; }
public bool Selected { get; set; }
public List<OrderAllocationFeeLineOutput> 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; }
}

View File

@ -125,6 +125,21 @@ public class OrderOutput
/// </summary>
public int? CustomerSenderAddressId { get; set; }
/// <summary>
/// 到仓方式:selfShip / doorPickup。
/// </summary>
public string? InboundMethod { get; set; }
/// <summary>
/// 关联揽收单 Id。
/// </summary>
public int? PickupOrderId { get; set; }
/// <summary>
/// 关联揽收单号。
/// </summary>
public string? PickupNo { get; set; }
/// <summary>
/// 幂等请求标识。
/// </summary>
@ -145,6 +160,16 @@ public class OrderOutput
/// </summary>
public string? AllocationPendingReason { get; set; }
/// <summary>
/// 最近一次排货方案。
/// </summary>
public string? AllocationScheme { get; set; }
/// <summary>
/// 是否已有排货过程快照。
/// </summary>
public bool HasAllocationTrace { get; set; }
/// <summary>
/// 申报币种。
/// </summary>

View File

@ -154,4 +154,29 @@ public class OrderParcelCreateInput
/// 仅保存为草稿,不执行预报计费与状态推进。
/// </summary>
public bool SaveAsDraft { get; set; }
/// <summary>
/// 到仓方式:selfShip / doorPickup。
/// </summary>
public string? InboundMethod { get; set; }
/// <summary>
/// 上门揽收时发件地址簿 Id。
/// </summary>
public int? SenderAddressId { get; set; }
/// <summary>
/// 期望揽收时间(上门揽收)。
/// </summary>
public DateTime? ExpectPickupTime { get; set; }
/// <summary>
/// 承运商编码(可后补)。
/// </summary>
public string? CarrierCode { get; set; }
/// <summary>
/// 承运服务(可后补)。
/// </summary>
public string? CarrierService { get; set; }
}

View File

@ -39,4 +39,19 @@ public class OrderParcelCreateOutput
/// 收件国家/地区中文名称。
/// </summary>
public string? ConsigneeCountryName { get; set; }
/// <summary>
/// 同步创建的揽收单号(上门揽收成功时)。
/// </summary>
public string? PickupNo { get; set; }
/// <summary>
/// 同步创建的揽收单 Id。
/// </summary>
public int? PickupOrderId { get; set; }
/// <summary>
/// 揽收单创建失败原因(运单已成功时仅作提示)。
/// </summary>
public string? PickupCreateError { get; set; }
}

View File

@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 单票重新排货入参。
/// </summary>
public class OrderReallocateInput
{
[Required]
public int Id { get; set; }
/// <summary>
/// 排货方案:costLowest / timeFastest / overallBest / designated。
/// </summary>
[Required]
[StringLength(32)]
public string Scheme { get; set; } = string.Empty;
/// <summary>
/// 指定渠道时必填。
/// </summary>
public int? ServiceChannelId { get; set; }
}
/// <summary>
/// 批量重新排货入参(同步兜底)。
/// </summary>
public class OrderReallocateBatchInput
{
[Required]
[MinLength(1)]
public List<int> OrderIds { get; set; } = [];
[Required]
[StringLength(32)]
public string Scheme { get; set; } = string.Empty;
public int? ServiceChannelId { get; set; }
}

View File

@ -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<int> OrderIds { get; set; } = [];
[StringLength(512)]
public string? Remark { get; set; }
/// <summary>
/// 可选:创建时直接写入对客运费(未写则保持未确认)。
/// </summary>
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<int> 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<PickupOrderLineOutput> Orders { get; set; } = [];
public List<PickupOrderRejectItem> Rejected { get; set; } = [];
}

View File

@ -140,6 +140,7 @@ public class CustomerAccountFlowService : ApplicationService
{
CustomerAccountFlowBizTypes.Recharge => "充值入账",
CustomerAccountFlowBizTypes.OrderPay => "订单支付",
CustomerAccountFlowBizTypes.PickupPay => "揽收支付",
_ => bizType
};
}

View File

@ -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<InboundRecord, InboundRecordOutput>(savedEntity);
var refreshed = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim());
var output = ObjectMapper.Map<InboundRecord, InboundRecordOutput>(savedEntity);
ApplyOrderStatusToInboundOutput(output, refreshed);
return output;
}
/// <summary>
@ -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<string>()
.ToArray();
var orderMap = await BuildOrderMapByWaybillNosAsync(waybillNos);
var items = entities.Select(entity =>
{
var output = ObjectMapper.Map<InboundRecord, InboundRecordOutput>(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<InboundRecordOutput> { Items = items };
}
private async Task<Dictionary<string, Order>> BuildOrderMapByWaybillNosAsync(string[] waybillNos)
{
var map = new Dictionary<string, Order>(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;
}
/// <summary>
/// 短时幂等:处理中重复扫码直接拒绝,避免双写入库记录与双投计费任务。
/// </summary>
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("订单入库处理中,请勿重复扫描");
}
}
}

View File

@ -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),

View File

@ -268,6 +268,8 @@ public static class OrderBatchJobItemFeedback
OrderBatchActionCodes.Delete => "删除成功",
OrderBatchActionCodes.Print => "打印成功",
OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功",
OrderBatchActionCodes.InboundBilling => "入库计费/排货完成",
OrderBatchActionCodes.Reallocate => "重新排货成功",
OrderBatchActionCodes.MarkProblematic => "问题件创建成功",
OrderBatchActionCodes.MarkClaimed => "理赔件创建成功",
OrderBatchActionCodes.HandleStatusUpdate => "处理状态更新成功",

View File

@ -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,

View File

@ -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<AutoTriggerRuntimeService> logger) : IScopedDependency
{
private const string InterceptOperation = "拦截";
/// <summary>
/// 后台异步触发,避免拉长入库/出库扫码请求。
/// </summary>
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<AutoTriggerRuntimeService>();
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)

View File

@ -166,6 +166,13 @@ public partial class OrderService
public Task<OrderBatchJobOutput> BatchMarkProblematicAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.MarkProblematic, input);
/// <summary>
/// 异步批量重新排货。
/// </summary>
[HttpPost("/order/batch/reallocate")]
public Task<OrderBatchJobOutput> BatchReallocateAsync([FromBody] OrderBatchJobCreateInput input) =>
EnqueueOrderBatchAsync(OrderBatchActionCodes.Reallocate, input);
/// <summary>
/// 异步批量标记已认领。
/// </summary>

View File

@ -110,6 +110,18 @@ public partial class OrderService
});
}
internal async Task ReallocateForBatchAsync(
int orderId,
string? payloadJson,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
var input = DeserializeBatchPayload<OrderReallocateBatchInput>(payloadJson)
?? throw new BusinessException("重新排货参数无效");
var order = await GetOrderOrThrowAsync(orderId);
await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId);
}
internal async Task UpdateHandleStatusForBatchAsync(
int orderId,
string? payloadJson,

View File

@ -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,

View File

@ -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();
}
/// <summary>
/// 重跑入库计费(应收 wipe+upsert + 排货应付)。用于队列投递失败或待排货补救。
/// </summary>
[HttpPost("/order/fee/rerun-inbound-billing")]
public async Task<OrderOutput> RerunInboundBillingAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
await ProcessInboundBillingForBatchAsync(order.Id);
order = await GetOrderOrThrowAsync(input.Id);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 查询排货过程:优先返回快照;无快照或 live=true 时按当前规则现算(不落库)。
/// </summary>
[HttpGet("/order/allocation-trace")]
public async Task<OrderAllocationTraceOutput> GetAllocationTraceAsync(
[FromQuery] int id,
[FromQuery] bool live = false)
{
var order = await GetOrderOrThrowAsync(id);
if (!live && !string.IsNullOrWhiteSpace(order.AllocationTraceJson))
{
try
{
var snapshot = JsonConvert.DeserializeObject<OrderAllocationTraceOutput>(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<OrderAllocationTraceOutput> 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
};
}
}
/// <summary>
/// 单票重新排货:仅重写应付与服务渠道,不动应收。
/// </summary>
[HttpPost("/order/fee/reallocate")]
public async Task<OrderOutput> 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();
}
/// <summary>
/// 批量重新排货(同步兜底,队列关闭时使用)。
/// </summary>
[HttpPost("/order/fee/reallocate-batch")]
public async Task<OrderBatchActionResultOutput> 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;
}
/// <summary>
/// 未付款订单更新附加服务并重算当前阶段应收(可选尝试重算应付)。
/// </summary>
@ -416,6 +605,29 @@ public partial class OrderService
private async Task RecalculatePayableCoreAsync(Order order)
{
await ReallocateCoreAsync(order, scheme: null, serviceChannelId: null, enforceReallocateGate: false);
}
/// <summary>
/// 重新排货核心:按方案/指定渠道确认服务渠道并重写应付。
/// </summary>
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<OrderWeight> weights)
{
var (_, chargingStage) = ResolveReceivableBillingStage(order);

View File

@ -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<int, byte> InboundBillingInFlight = new();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
@ -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<OrderParcelCreateOutput> CreateParcelCoreAsync(
@ -752,28 +763,162 @@ public partial class OrderService
IReadOnlyList<string> 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<AllocationResolver.ChannelCandidate> Candidates { get; init; } = [];
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
public AllocationResolver.ChannelCandidate? Selected { get; set; }
public string? FailReason { get; set; }
}
private async Task<AllocationEvaluation> EvaluateAllocationAsync(
Order order,
SalesProduct product,
ProductSnapshotBinding snapshotBinding,
IReadOnlyList<string> zoneKeys,
decimal chargeableWeight,
string chargingStage,
string? schemeOverride,
int? forceServiceChannelId)
{
var skipped = new List<OrderAllocationSkippedOutput>();
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<AllocationResolver.ChannelCandidate>();
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<OrderAllocationCandidateOutput> SortAllocationCandidates(
string? scheme,
List<OrderAllocationCandidateOutput> candidates)
{
Func<OrderAllocationCandidateOutput, decimal> 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);
/// <summary>
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
@ -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
}
/// <summary>
/// 入库收货后:仅覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并更新订单状态。
/// 入库收货:同步只写入 receive 件重尺与收货时间,状态先置待排货;
/// 应收/应付/排货走异步任务,失败不回滚入库。
/// </summary>
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 _);
}
}
/// <summary>
/// 入库异步计费:重算 inbound 应收 + 尝试排货写应付;失败仅落待排货原因。
/// </summary>
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<ILoggerFactory>()
.CreateLogger("Oms.InboundBilling");
try
{
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
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<int>() : 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
}
}
}
/// <summary>
/// 幂等 RequestId:进行中任务复用并跳过;已结束任务换新 ID 允许重跑。
/// 返回 null 表示已有进行中任务,无需再投递。
/// </summary>
private async Task<string?> ResolveInboundBillingRequestIdAsync(int orderId)
{
var batchJobRepository = LazyServiceProvider.GetRequiredService<IOrderBatchJobRepository>();
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);
/// <summary>
/// 出库发货后:仅覆盖 ship 件重尺、重算计费重并更新状态;不再重算应收。
/// </summary>

View File

@ -32,6 +32,7 @@ public partial class OrderService : ApplicationService
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService<IPickupOrderRepository>();
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
private LabelPdfGenerationService LabelPdfGenerationService =>
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
@ -405,7 +406,80 @@ public partial class OrderService : ApplicationService
[HttpPost("/order/parcel/create")]
public async Task<OrderParcelCreateOutput> 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<PickupOrderService>();
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;
}
}
/// <summary>

View File

@ -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;
/// <summary>
/// 揽收单(1 揽收 : N 物流)管理。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Order")]
public class PickupOrderService : ApplicationService
{
private static readonly HashSet<string> BindableOrderStatuses = new(StringComparer.OrdinalIgnoreCase)
{
OrderStatusCodes.Draft,
OrderStatusCodes.Predeclared,
OrderStatusCodes.PendingAllocation,
OrderStatusCodes.PendingPayment,
OrderStatusCodes.PendingShip,
OrderStatusCodes.PendingOnline,
};
private IPickupOrderRepository PickupOrderRepository =>
LazyServiceProvider.GetRequiredService<IPickupOrderRepository>();
private IPickupOrderLineRepository PickupOrderLineRepository =>
LazyServiceProvider.GetRequiredService<IPickupOrderLineRepository>();
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService<IOrderRepository>();
private ICustomerRepository CustomerRepository =>
LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private ICustomerSenderAddressRepository CustomerSenderAddressRepository =>
LazyServiceProvider.GetRequiredService<ICustomerSenderAddressRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository =>
LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ICountryRepository CountryRepository =>
LazyServiceProvider.GetRequiredService<ICountryRepository>();
/// <summary>
/// 创建揽收单并挂载物流单(原子:全部校验通过才落库)。
/// </summary>
[HttpPost("/order/pickup-order/create")]
public async Task<PickupOrderOutput> 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<PickupOrderRejectItem>();
var accepted = new List<Order>();
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);
}
/// <summary>
/// 追加物流单到已有揽收单。
/// </summary>
[HttpPost("/order/pickup-order/add-orders")]
public async Task<PickupOrderOutput> 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<PickupOrderRejectItem>();
var accepted = new List<Order>();
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);
}
/// <summary>
/// 从揽收单移除物流单;移除后若无关联则自动取消揽收单。
/// </summary>
[HttpPost("/order/pickup-order/remove-orders")]
public async Task<PickupOrderOutput> 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);
}
/// <summary>
/// 揽收单分页列表。
/// </summary>
[HttpPost("/order/pickup-order/list")]
public async Task<OmsPageResult<PickupOrderOutput>> 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<PickupOrderOutput>();
foreach (var item in pageItems)
{
outputs.Add(await MapOutputAsync(item, includeOrders: false));
}
return new OmsPageResult<PickupOrderOutput> { Items = outputs, Total = total };
}
/// <summary>
/// 揽收单详情。
/// </summary>
[HttpGet("/order/pickup-order/detail")]
public async Task<PickupOrderOutput> GetDetailAsync([FromQuery] int id)
{
var pickup = await GetOwnedPickupOrThrowAsync(id);
return await MapOutputAsync(pickup, includeOrders: true);
}
/// <summary>
/// 确认对客揽收运费。
/// </summary>
[HttpPost("/order/pickup-order/confirm-freight")]
public async Task<PickupOrderOutput> 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);
}
/// <summary>
/// 客户支付揽收运费(余额)。
/// </summary>
[HttpPost("/order/pickup-order/pay")]
public async Task<PickupOrderOutput> 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<CustomerBalanceMutator>();
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);
}
/// <summary>
/// 补承运商并预约(draft → scheduled)。
/// </summary>
[HttpPost("/order/pickup-order/schedule")]
public async Task<PickupOrderOutput> 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);
}
/// <summary>
/// 改约(未取件)。
/// </summary>
[HttpPost("/order/pickup-order/reschedule")]
public async Task<PickupOrderOutput> 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);
}
/// <summary>
/// 取消揽收单。
/// </summary>
[HttpPost("/order/pickup-order/cancel")]
public async Task<PickupOrderOutput> 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);
}
/// <summary>
/// 标记已取件。
/// </summary>
[HttpPost("/order/pickup-order/mark-picked")]
public async Task<PickupOrderOutput> 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);
}
/// <summary>
/// 标记已到仓。
/// </summary>
[HttpPost("/order/pickup-order/mark-inbound")]
public async Task<PickupOrderOutput> 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);
}
/// <summary>
/// 供小包创建成功后内部挂载(失败由调用方吞掉并回显)。
/// </summary>
public async Task<PickupOrderOutput> CreateForParcelInternalAsync(PickupOrderCreateInput input)
{
return await CreateAsync(input);
}
private async Task<PickupOrder> 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<string> 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<PickupOrderOutput> 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;
}
}

View File

@ -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,

View File

@ -5,4 +5,15 @@ public static class AllocationSchemeCodes
public const string CostLowest = "costLowest";
public const string TimeFastest = "timeFastest";
public const string OverallBest = "overallBest";
/// <summary>运营指定渠道(仅重新排货场景,不写入销售产品默认方案)。</summary>
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);
}

View File

@ -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";

View File

@ -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";

View File

@ -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";
}

View File

@ -60,6 +60,24 @@ public class Order : OmsAuditedAggregateRoot
[SugarColumn(Length = 512, IsNullable = true)]
public string? AllocationPendingReason { get; set; }
/// <summary>
/// 最近一次排货方案:costLowest / timeFastest / overallBest / designated。
/// </summary>
[SugarColumn(Length = 32, IsNullable = true)]
public string? AllocationScheme { get; set; }
/// <summary>
/// 最近一次排货过程快照 JSON。
/// </summary>
[SugarColumn(ColumnDataType = "json", IsNullable = true)]
public string? AllocationTraceJson { get; set; }
/// <summary>
/// 排货快照写入时间。
/// </summary>
[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
/// </summary>
public int? CustomerSenderAddressId { get; set; }
/// <summary>
/// 到仓方式:selfShip / doorPickup。
/// </summary>
[SugarColumn(Length = 16, IsNullable = true)]
public string? InboundMethod { get; set; }
/// <summary>
/// 关联揽收单 Id(冗余,真源为 t_pickup_order_line)。
/// </summary>
[SugarColumn(IsNullable = true)]
public int? PickupOrderId { get; set; }
public string? OrganizationId { get; set; }
public int SecretLevel { get; set; } = 2;

View File

@ -0,0 +1,114 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.OrdersAggregate.Entities;
/// <summary>
/// 揽收单(上门取件履约 + 对客收款 + 承运结算)。
/// </summary>
[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; }
}

View File

@ -0,0 +1,17 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.OrdersAggregate.Entities;
/// <summary>
/// 揽收单关联物流订单。
/// </summary>
[SugarTable("t_pickup_order_line")]
public class PickupOrderLine : OmsAuditedAggregateRoot
{
public int PickupOrderId { get; set; }
public int OrderId { get; set; }
public int SortNo { get; set; }
}

View File

@ -0,0 +1,24 @@
using Fake.Domain.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Domain.OrdersAggregate.Repositories;
public interface IPickupOrderRepository : IRepository<PickupOrder>
{
Task<PickupOrder?> GetByPickupNoAsync(string pickupNo, CancellationToken cancellationToken = default);
Task<PickupOrder?> GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default);
}
public interface IPickupOrderLineRepository : IRepository<PickupOrderLine>
{
Task<List<PickupOrderLine>> GetByPickupOrderIdAsync(int pickupOrderId, CancellationToken cancellationToken = default);
Task<PickupOrderLine?> GetActiveByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
Task<List<PickupOrderLine>> GetActiveByOrderIdsAsync(IReadOnlyCollection<int> orderIds, CancellationToken cancellationToken = default);
Task HardDeleteByPickupOrderIdAsync(int pickupOrderId, CancellationToken cancellationToken = default);
Task HardDeleteAsync(PickupOrderLine line, CancellationToken cancellationToken = default);
}

View File

@ -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<PickupOrder>, IPickupOrderRepository, IScopedDependency
{
public Task<PickupOrder?> GetByPickupNoAsync(string pickupNo, CancellationToken cancellationToken = default)
{
var no = pickupNo.Trim();
return FirstOrDefaultAsync(x => x.PickupNo == no, cancellationToken);
}
public Task<PickupOrder?> GetOwnedAsync(int id, int customerId, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId, cancellationToken);
}
}
public class PickupOrderLineRepository : OmsSqlSugarRepository<PickupOrderLine>, IPickupOrderLineRepository, IScopedDependency
{
public Task<List<PickupOrderLine>> GetByPickupOrderIdAsync(int pickupOrderId, CancellationToken cancellationToken = default)
{
return GetListAsync(x => x.PickupOrderId == pickupOrderId, cancellationToken: cancellationToken);
}
public Task<PickupOrderLine?> GetActiveByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
}
public async Task<List<PickupOrderLine>> GetActiveByOrderIdsAsync(
IReadOnlyCollection<int> 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<PickupOrderLine>()
.Where(x => x.PickupOrderId == pickupOrderId)
.ExecuteCommandAsync(cancellationToken);
}
public async Task HardDeleteAsync(PickupOrderLine line, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<PickupOrderLine>()
.Where(x => x.Id == line.Id)
.ExecuteCommandAsync(cancellationToken);
}
}