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:
parent
cc100d40cf
commit
c02cb21049
6
sql/20260801_order_allocation_trace.sql
Normal file
6
sql/20260801_order_allocation_trace.sql
Normal 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`;
|
||||
78
sql/20260803_pickup_order.sql
Normal file
78
sql/20260803_pickup_order.sql
Normal 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`);
|
||||
@ -59,4 +59,9 @@ public class InboundOrderLookupOutput
|
||||
/// 订单状态名称。
|
||||
/// </summary>
|
||||
public string? OrderStatusName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待排货/计费失败原因。
|
||||
/// </summary>
|
||||
public string? AllocationPendingReason { get; set; }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
81
src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs
Normal file
81
src/Oms.Application/Dtos/Orders/OrderAllocationTraceDtos.cs
Normal 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; }
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
40
src/Oms.Application/Dtos/Orders/OrderReallocateDtos.cs
Normal file
40
src/Oms.Application/Dtos/Orders/OrderReallocateDtos.cs
Normal 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; }
|
||||
}
|
||||
202
src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
Normal file
202
src/Oms.Application/Dtos/Orders/PickupOrderDtos.cs
Normal 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; } = [];
|
||||
}
|
||||
@ -140,6 +140,7 @@ public class CustomerAccountFlowService : ApplicationService
|
||||
{
|
||||
CustomerAccountFlowBizTypes.Recharge => "充值入账",
|
||||
CustomerAccountFlowBizTypes.OrderPay => "订单支付",
|
||||
CustomerAccountFlowBizTypes.PickupPay => "揽收支付",
|
||||
_ => bizType
|
||||
};
|
||||
}
|
||||
|
||||
@ -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("订单入库处理中,请勿重复扫描");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -268,6 +268,8 @@ public static class OrderBatchJobItemFeedback
|
||||
OrderBatchActionCodes.Delete => "删除成功",
|
||||
OrderBatchActionCodes.Print => "打印成功",
|
||||
OrderBatchActionCodes.GenerateLabelPdf => "面单生成成功",
|
||||
OrderBatchActionCodes.InboundBilling => "入库计费/排货完成",
|
||||
OrderBatchActionCodes.Reallocate => "重新排货成功",
|
||||
OrderBatchActionCodes.MarkProblematic => "问题件创建成功",
|
||||
OrderBatchActionCodes.MarkClaimed => "理赔件创建成功",
|
||||
OrderBatchActionCodes.HandleStatusUpdate => "处理状态更新成功",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
867
src/Oms.Application/Services/Orders/PickupOrderService.cs
Normal file
867
src/Oms.Application/Services/Orders/PickupOrderService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
}
|
||||
@ -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;
|
||||
|
||||
114
src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs
Normal file
114
src/Oms.Domain/OrdersAggregate/Entities/PickupOrder.cs
Normal 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; }
|
||||
}
|
||||
17
src/Oms.Domain/OrdersAggregate/Entities/PickupOrderLine.cs
Normal file
17
src/Oms.Domain/OrdersAggregate/Entities/PickupOrderLine.cs
Normal 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; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
62
src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs
Normal file
62
src/Oms.SqlSugarCore/Orders/PickupOrderRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user