admin/src/Oms.Application/Services/Orders/PickupOrderService.cs
zzh 26f9d8be7f
All checks were successful
Build and Deploy / deploy (push) Successful in 1m43s
feat: 揽收单预报下游改从 t_pickup_order 组装 createOrderOne
新增 /order/pickup-order/downstream-forward,productCode/supplierCode/地址重量商品均取揽收单,不再依赖关联物流单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:17:09 +08:00

1205 lines
44 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
using Oms.Application.Integrations.Itfx;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
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>();
private ISalesProductRepository SalesProductRepository =>
LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IServiceChannelRepository ServiceChannelRepository =>
LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
private IServiceChannelGroupRepository ServiceChannelGroupRepository =>
LazyServiceProvider.GetRequiredService<IServiceChannelGroupRepository>();
private IItfxForwardClient ItfxForwardClient =>
LazyServiceProvider.GetRequiredService<IItfxForwardClient>();
private IItfxMetadataClient ItfxMetadataClient =>
LazyServiceProvider.GetRequiredService<IItfxMetadataClient>();
/// <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 direction = PickupDirectionCodes.NormalizeOrDefault(input.Direction);
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 (!CustomerAddressTypeCodes.IsPickupEligible(addressType))
{
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;
}
if (!string.IsNullOrWhiteSpace(input.RequestId))
{
var existedByRequest = await PickupOrderRepository.FirstOrDefaultAsync(x => x.RequestId == input.RequestId.Trim());
if (existedByRequest != null)
{
if (existedByRequest.CustomerId != customerId)
{
throw new BusinessException("幂等请求已存在");
}
return await MapOutputAsync(existedByRequest, includeOrders: true);
}
}
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 orgId = accepted
.Select(x => x.OrganizationId)
.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
var secretLevel = accepted
.Select(x => x.SecretLevel)
.FirstOrDefault(x => x > 0);
if (secretLevel <= 0)
{
secretLevel = 2;
}
var entity = new PickupOrder
{
PickupNo = await GeneratePickupNoAsync(),
RequestId = NormalizeOptional(input.RequestId),
CustomerId = customerId,
OrganizationId = orgId,
SecretLevel = secretLevel,
SenderAddressId = senderAddress.Id,
Direction = direction,
SenderName = senderAddress.SenderName ?? string.Empty,
SenderCompany = senderAddress.Company,
SenderCountry = senderAddress.Country,
SenderCountryCode = senderCountryCode,
SenderProvince = senderAddress.Province,
SenderCity = senderAddress.City,
SenderZipCode = senderAddress.ZipCode,
SenderStreetAddress1 = senderAddress.StreetAddress1 ?? string.Empty,
SenderStreetAddress2 = senderAddress.StreetAddress2,
SenderStreetAddress3 = senderAddress.StreetAddress3,
SenderHouseNumber = senderAddress.HouseNumber,
SenderPhone = senderAddress.Phone,
SenderTelePhone = senderAddress.TelePhone,
SenderEmail = senderAddress.Email,
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,
ServiceNo = order.ServiceNo,
ClientNo = order.ClientNo,
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,
ServiceNo = order.ServiceNo,
ClientNo = order.ClientNo,
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 direction = string.IsNullOrWhiteSpace(input.Direction)
? null
: PickupDirectionCodes.NormalizeOrDefault(input.Direction);
var all = await PickupOrderRepository.GetListAsync(x =>
(customerId == null || x.CustomerId == customerId.Value) &&
(direction == null || x.Direction == direction) &&
(status == null || x.Status == status) &&
(payStatus == null || x.PayStatus == payStatus) &&
(input.WarehouseId == null || input.WarehouseId <= 0 || x.WarehouseId == input.WarehouseId.Value) &&
(input.CreateTimeStart == null || x.CreateTime >= input.CreateTimeStart) &&
(input.CreateTimeEnd == null || x.CreateTime <= input.CreateTimeEnd) &&
(input.ExpectPickupTimeStart == null || x.ExpectPickupTime >= input.ExpectPickupTimeStart) &&
(input.ExpectPickupTimeEnd == null || x.ExpectPickupTime <= input.ExpectPickupTimeEnd) &&
(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>
/// 揽收单状态计数(Tab 徽章)。
/// </summary>
[HttpGet("/order/pickup-order/status-counts")]
public async Task<PickupOrderStatusCountsOutput> GetStatusCountsAsync(
[FromQuery] int? customerId,
[FromQuery] string? direction = null)
{
var resolvedCustomerId = await WarehouseCustomerHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
customerId);
var directionFilter = string.IsNullOrWhiteSpace(direction)
? null
: PickupDirectionCodes.NormalizeOrDefault(direction);
var all = await PickupOrderRepository.GetListAsync(x =>
(resolvedCustomerId == null || x.CustomerId == resolvedCustomerId.Value) &&
(directionFilter == null || x.Direction == directionFilter));
var terminal = new[] { PickupOrderStatusCodes.Cancelled, PickupOrderStatusCodes.Failed };
var pendingPayment = all.Count(x =>
!terminal.Contains(x.Status) &&
x.PayStatus != PickupPayStatusCodes.Paid &&
x.ReceivableAmount is > 0);
var counts = all
.GroupBy(x => x.Status ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
return new PickupOrderStatusCountsOutput
{
Total = all.Count,
PendingPayment = pendingPayment,
Counts = counts
};
}
/// <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 PayCoreAsync(input.Id);
return await MapOutputAsync(pickup, includeOrders: true);
}
/// <summary>
/// 批量支付揽收运费(逐单扣余额,部分失败不回滚已成功项)。
/// </summary>
[HttpPost("/order/pickup-order/pay-batch")]
public async Task<PickupOrderBatchPayOutput> PayBatchAsync([FromBody] PickupOrderIdsInput input)
{
var ids = (input.Ids ?? []).Where(x => x > 0).Distinct().ToList();
if (ids.Count == 0)
{
throw new BusinessException("请至少选择一张揽收单");
}
if (ids.Count > 100)
{
throw new BusinessException("单次最多支付 100 张揽收单");
}
var output = new PickupOrderBatchPayOutput();
foreach (var id in ids)
{
try
{
var pickup = await PayCoreAsync(id);
var amount = pickup.ReceivableAmount ?? pickup.FreightAmount ?? 0;
output.SuccessCount++;
output.PaidAmount += amount;
output.Items.Add(new PickupOrderBatchPayItemOutput
{
Id = pickup.Id,
PickupNo = pickup.PickupNo,
Success = true,
Message = "支付成功",
Amount = amount
});
}
catch (Exception ex)
{
output.FailCount++;
var pickupNo = await TryGetPickupNoAsync(id);
output.Items.Add(new PickupOrderBatchPayItemOutput
{
Id = id,
PickupNo = pickupNo,
Success = false,
Message = ex.Message
});
}
}
output.Message = $"支付完成:成功 {output.SuccessCount},失败 {output.FailCount}";
return output;
}
private async Task<PickupOrder> PayCoreAsync(int id)
{
var pickup = await GetOwnedPickupOrThrowAsync(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 pickup;
}
private async Task<string?> TryGetPickupNoAsync(int id)
{
var pickup = await PickupOrderRepository.FirstOrDefaultAsync(x => x.Id == id);
return pickup?.PickupNo;
}
/// <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 carrierService = NormalizeOptional(input.CarrierService);
if (carrierService == 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.CarrierService = carrierService;
pickup.CarrierCode = NormalizeOptional(input.CarrierCode);
var carrierWaybillNo = NormalizeOptional(input.CarrierWaybillNo);
if (carrierWaybillNo != null)
{
pickup.CarrierWaybillNo = carrierWaybillNo;
}
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);
}
/// <summary>
/// 揽收单预报下游:createOrderOne 请求体全部从 t_pickup_order 组装(不读关联物流单)。
/// </summary>
[HttpPost("/order/pickup-order/downstream-forward")]
public async Task<OrderDownstreamForwardResult> DownstreamForwardAsync([FromBody] PickupOrderIdInput input)
{
var pickup = await GetOwnedPickupOrThrowAsync(input.Id);
if (string.IsNullOrWhiteSpace(pickup.CarrierService))
{
throw new BusinessException("请先选择承运服务后再预报下游");
}
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == pickup.WarehouseId);
if (warehouse == null)
{
throw new BusinessException("仓库不存在");
}
var (body, _) = await PickupOrderDownstreamCreateOneBodyBuilder.BuildAsync(
pickup,
warehouse,
SalesProductRepository,
ServiceChannelRepository);
if (string.IsNullOrWhiteSpace(body.ProductCode))
{
throw new BusinessException("无法解析下游 productCode,请检查承运服务对应渠道的预报编码");
}
var creds = await ResolvePickupDownstreamCredentialsAsync(pickup);
var forward = await ItfxForwardClient.ForwardCreateOrderOneAsync(
creds.SupplierCode,
creds.AppKey,
creds.AppSecret,
body);
if (forward.Success
&& ItfxCreateOrderOneDataParser.TryParse(forward.ResponseBody, out var data)
&& data != null)
{
var changed = false;
var waybill = !string.IsNullOrWhiteSpace(data.TrackNo)
? data.TrackNo.Trim()
: data.ServiceNo?.Trim();
if (!string.IsNullOrWhiteSpace(waybill)
&& !string.Equals(pickup.CarrierWaybillNo, waybill, StringComparison.Ordinal))
{
pickup.CarrierWaybillNo = waybill;
changed = true;
}
if (string.IsNullOrWhiteSpace(pickup.CarrierCode)
&& !string.IsNullOrWhiteSpace(creds.SupplierCode))
{
pickup.CarrierCode = creds.SupplierCode;
changed = true;
}
if (changed)
{
await PickupOrderRepository.UpdateAsync(pickup);
}
}
return new OrderDownstreamForwardResult
{
Success = forward.Success,
SupplierCode = creds.SupplierCode,
Url = forward.Url,
RequestJson = forward.RequestJson,
StatusCode = forward.StatusCode,
ResponseBody = forward.ResponseBody,
DownstreamServiceNo = pickup.CarrierWaybillNo,
TrackNo = pickup.CarrierWaybillNo,
};
}
/// <summary>
/// 凭证:优先揽收单 CarrierCode 作为 supplierCode;AppKey/Secret 仍按承运服务→渠道分组解析。
/// </summary>
private async Task<OrderDownstreamCredentials> ResolvePickupDownstreamCredentialsAsync(PickupOrder pickup)
{
var salesProductCode = pickup.CarrierService!.Trim();
var product = await SalesProductRepository.GetByProductCodeAsync(salesProductCode);
if (product == null)
{
throw new BusinessException($"未找到承运服务对应销售产品「{salesProductCode}」");
}
var providers = await SalesProductRepository.GetAllocationProvidersAsync(product.Id);
var channelId = providers
.Where(x => x.ServiceChannelId is > 0)
.OrderBy(x => x.SerialNumber)
.Select(x => x.ServiceChannelId!.Value)
.FirstOrDefault();
if (channelId <= 0)
{
throw new BusinessException($"销售产品「{salesProductCode}」未配置服务渠道");
}
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
if (channel == null)
{
throw new BusinessException("承运服务关联的服务渠道不存在");
}
if (!channel.GroupId.HasValue || channel.GroupId.Value <= 0)
{
throw new BusinessException($"渠道「{channel.ChannelCode}」未关联服务商分组");
}
var group = await ServiceChannelGroupRepository.FirstOrDefaultAsync(x => x.Id == channel.GroupId.Value);
if (group == null)
{
throw new BusinessException("服务商分组不存在或已删除");
}
if (group.AppId <= 0 || group.ApiServiceProviderId <= 0)
{
throw new BusinessException($"服务商「{group.Name}」未配置应用或 API 服务商");
}
var appInfo = await ItfxMetadataClient.GetAppAsync(group.AppId);
var appKey = appInfo?.AppKey?.Trim();
var appSecret = appInfo?.AppSecret?.Trim();
if (string.IsNullOrWhiteSpace(appKey) || string.IsNullOrWhiteSpace(appSecret))
{
throw new BusinessException(
"无法获取应用 AppKey/AppSecret,请检查服务商应用配置或「其他配置-API中心服务」");
}
var supplierCode = pickup.CarrierCode?.Trim();
if (string.IsNullOrWhiteSpace(supplierCode))
{
var sp = await ItfxMetadataClient.GetServiceProviderAsync(group.ApiServiceProviderId);
if (sp == null || string.IsNullOrWhiteSpace(sp.Code))
{
throw new BusinessException("API 服务商编码(supplierCode)未配置");
}
supplierCode = sp.Code.Trim();
}
return new OrderDownstreamCredentials
{
SupplierCode = supplierCode,
AppKey = appKey,
AppSecret = appSecret
};
}
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,
RequestId = entity.RequestId,
CustomerId = entity.CustomerId,
CustomerName = customerName,
OrganizationId = entity.OrganizationId,
SecretLevel = entity.SecretLevel,
SenderAddressId = entity.SenderAddressId,
Direction = PickupDirectionCodes.NormalizeOrDefault(entity.Direction),
SenderName = entity.SenderName,
SenderPhone = entity.SenderPhone,
SenderTelePhone = entity.SenderTelePhone,
SenderEmail = entity.SenderEmail,
SenderCompany = entity.SenderCompany,
SenderCountry = entity.SenderCountry,
SenderCountryCode = entity.SenderCountryCode,
SenderProvince = entity.SenderProvince,
SenderCity = entity.SenderCity,
SenderZipCode = entity.SenderZipCode,
SenderStreetAddress1 = entity.SenderStreetAddress1,
SenderStreetAddress2 = entity.SenderStreetAddress2,
SenderStreetAddress3 = entity.SenderStreetAddress3,
SenderHouseNumber = entity.SenderHouseNumber,
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,
UpdateTime = entity.UpdateTime
};
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;
}
}