feat(order): add order edit APIs, inbound lookup, and fee output

Support order detail/weight/declaration updates, receivable and payable recalculation, inbound scan order lookup, receive billing overwrite, and OrderOutput feeList for my-orders drawer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-26 17:30:41 +08:00
parent 5e26de6ff3
commit 82df5d1e79
13 changed files with 1189 additions and 53 deletions

View File

@ -65,8 +65,18 @@ public class SalesProductPriceListOutput
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 创建人。
/// </summary>
public string? CreateBy { get; set; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdateTime { get; set; }
/// <summary>
/// 修改人。
/// </summary>
public string? UpdateBy { get; set; }
}

View File

@ -0,0 +1,62 @@
namespace Oms.Application.Dtos.Operations;
/// <summary>
/// 入库扫描按单号查订单(只读展示销售产品、目的国)。
/// </summary>
public class InboundOrderLookupOutput
{
/// <summary>
/// 订单主键。
/// </summary>
public int OrderId { get; set; }
/// <summary>
/// 服务单号。
/// </summary>
public string ServiceNo { get; set; } = string.Empty;
/// <summary>
/// 客户单号。
/// </summary>
public string? ClientNo { get; set; }
/// <summary>
/// 跟踪号。
/// </summary>
public string? TrackNo { get; set; }
/// <summary>
/// 产品编码。
/// </summary>
public string? ProductCode { get; set; }
/// <summary>
/// 销售产品主键。
/// </summary>
public int? SalesProductId { get; set; }
/// <summary>
/// 销售产品名称。
/// </summary>
public string? SalesProductName { get; set; }
/// <summary>
/// 目的国家编码。
/// </summary>
public string? DestinationCountry { get; set; }
/// <summary>
/// 目的国家名称。
/// </summary>
public string? DestinationCountryName { get; set; }
/// <summary>
/// 订单状态码。
/// </summary>
public string OrderStatus { get; set; } = string.Empty;
/// <summary>
/// 订单状态名称。
/// </summary>
public string? OrderStatusName { get; set; }
}

View File

@ -0,0 +1,52 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 订单费用出参。
/// </summary>
public class OrderFeeOutput
{
/// <summary>
/// 主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 类型:receivable / payable。
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// 费用项目。
/// </summary>
public string CostItem { get; set; } = string.Empty;
/// <summary>
/// 金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; set; } = string.Empty;
/// <summary>
/// 下游服务商。
/// </summary>
public string? DownstreamProvider { get; set; }
/// <summary>
/// 价格版本号。
/// </summary>
public string? PriceNo { get; set; }
/// <summary>
/// 价格生效时间。
/// </summary>
public DateTime? PriceEffectiveTime { get; set; }
/// <summary>
/// 计费重量阶段。
/// </summary>
public string? ChargingStage { get; set; }
}

View File

@ -149,4 +149,19 @@ public class OrderOutput
/// 重量明细集合。
/// </summary>
public List<OrderWeightOutput> WeightList { get; set; } = [];
/// <summary>
/// 费用明细集合。
/// </summary>
public List<OrderFeeOutput> FeeList { get; set; } = [];
/// <summary>
/// 计费重(优先收货行,其次预报行)。
/// </summary>
public decimal? ChargeableWeight { get; set; }
/// <summary>
/// 应收合计(当前有效应收费用之和)。
/// </summary>
public decimal? ReceivableAmount { get; set; }
}

View File

@ -0,0 +1,91 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 订单详情编辑入参。
/// </summary>
public class OrderUpdateInput
{
/// <summary>
/// 订单主键。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 客户单号。
/// </summary>
[StringLength(64)]
public string? ClientNo { get; set; }
/// <summary>
/// 跟踪号。
/// </summary>
[StringLength(64)]
public string? TrackNo { get; set; }
/// <summary>
/// 渠道编码。
/// </summary>
[StringLength(64)]
public string? ChannelCode { get; set; }
/// <summary>
/// 货物属性。
/// </summary>
[StringLength(32)]
public string? CargoAttribute { get; set; }
/// <summary>
/// 仓库主键。
/// </summary>
public int? WarehouseId { get; set; }
/// <summary>
/// 是否 COD。
/// </summary>
public bool? IsCod { get; set; }
/// <summary>
/// COD 币种。
/// </summary>
[StringLength(8)]
public string? CodCurrency { get; set; }
/// <summary>
/// COD 金额。
/// </summary>
public decimal? CodSum { get; set; }
/// <summary>
/// 申报币种。
/// </summary>
[StringLength(8)]
public string? DeclaredCurrency { get; set; }
/// <summary>
/// 申报金额。
/// </summary>
public decimal? DeclaredSum { get; set; }
/// <summary>
/// 件数。
/// </summary>
public int? Quantity { get; set; }
/// <summary>
/// 寄件人。
/// </summary>
public OrderParcelSenderInput? Sender { get; set; }
/// <summary>
/// 收件人。
/// </summary>
public OrderParcelReceiverInput? Receiver { get; set; }
/// <summary>
/// 申报明细;传入时全量替换原申报行。
/// </summary>
public List<OrderParcelItemInput>? Items { get; set; }
}

View File

@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 订单件重尺编辑入参。
/// </summary>
public class OrderWeightUpdateInput
{
/// <summary>
/// 订单主键。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 件重尺行。
/// </summary>
[Required]
public List<OrderWeightItemInput> Weights { get; set; } = [];
}
/// <summary>
/// 单条件重尺编辑项。
/// </summary>
public class OrderWeightItemInput
{
/// <summary>
/// 类型:forecast/receive/ship/downstream。
/// </summary>
[Required]
[StringLength(16)]
public string Type { get; set; } = string.Empty;
/// <summary>
/// 长度(cm)。
/// </summary>
public decimal? Length { get; set; }
/// <summary>
/// 宽度(cm)。
/// </summary>
public decimal? Width { get; set; }
/// <summary>
/// 高度(cm)。
/// </summary>
public decimal? Height { get; set; }
/// <summary>
/// 实重(kg)。
/// </summary>
public decimal? ActualWeight { get; set; }
}

View File

@ -88,7 +88,9 @@ public partial class SalesProductService
Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [],
Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [],
CreateTime = price.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime)
CreateBy = price.CreateUserId <= 0 ? null : price.CreateUserId.ToString(),
UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime),
UpdateBy = price.UpdateUserId <= 0 ? null : price.UpdateUserId.ToString()
}).ToList();
}

View File

@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Operations;
using Oms.Application.Dtos.Shared;
using Oms.Application.Services.Orders;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OperationsAggregate.Entities;
using Oms.Domain.OperationsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.SystemAggregate.Repositories;
namespace Oms.Application.Services.Operations;
@ -17,10 +19,60 @@ namespace Oms.Application.Services.Operations;
public class InboundRecordService : ApplicationService
{
private IInboundRecordRepository InboundRecordRepository => LazyServiceProvider.GetRequiredService<IInboundRecordRepository>();
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
/// <summary>
/// 按单号查询订单(入库扫描展示销售产品、目的国,只读)。
/// </summary>
[HttpGet("/operation/inbound/order-lookup")]
public async Task<InboundOrderLookupOutput> LookupOrderAsync([FromQuery] string waybillNo)
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
throw new BusinessException("请扫描或输入单号");
}
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
var output = new InboundOrderLookupOutput
{
OrderId = order.Id,
ServiceNo = order.ServiceNo,
ClientNo = order.ClientNo,
TrackNo = order.TrackNo,
ProductCode = order.ProductCode,
DestinationCountry = order.ConsigneeCountry,
OrderStatus = order.OrderStatus,
OrderStatusName = OmsDocHelper.GetOrderStatusName(order.OrderStatus)
};
if (!string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product != null)
{
output.SalesProductId = product.Id;
output.SalesProductName = product.ProductName;
}
}
if (!string.IsNullOrWhiteSpace(order.ConsigneeCountry))
{
var country = await CountryRepository.FirstOrDefaultAsync(x => x.CountryCode == order.ConsigneeCountry);
output.DestinationCountryName = country?.ChineseName ?? country?.EnglishName ?? order.ConsigneeCountry;
}
return output;
}
/// <summary>
/// 新增入库记录。
/// </summary>
@ -35,29 +87,55 @@ public class InboundRecordService : ApplicationService
[HttpPost("/operation/inbound/create")]
public async Task<InboundRecordOutput> CreateAsync(CreateInboundRecordInput input)
{
if (string.IsNullOrWhiteSpace(input.WaybillNo))
{
throw new BusinessException("运单号不能为空");
}
var order = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
if (!input.Weight.HasValue || !input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue
|| input.Weight <= 0 || input.Length <= 0 || input.Width <= 0 || input.Height <= 0)
{
throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
}
int? salesProductId = null;
string? destinationCountry = order.ConsigneeCountry;
if (!string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
salesProductId = product?.Id;
}
var now = DateTime.Now;
var operatorId = CurrentUser.GetId<int>();
var entity = new InboundRecord
{
WaybillNo = input.WaybillNo,
WaybillNo = input.WaybillNo.Trim(),
Weight = input.Weight,
Length = input.Length,
Width = input.Width,
Height = input.Height,
SalesProductId = input.SalesProductId,
DestinationCountry = input.DestinationCountry,
WarehouseId = input.WarehouseId,
SalesProductId = salesProductId,
DestinationCountry = destinationCountry,
WarehouseId = input.WarehouseId ?? order.WarehouseId,
OperateTime = now,
OperatorId = operatorId
};
var savedEntity = await InboundRecordRepository.InsertAsync(entity);
await OrderService.ApplyReceiveBillingByWaybillAsync(
input.WaybillNo,
input.WaybillNo.Trim(),
input.Length,
input.Width,
input.Height,
input.Weight);
var savedEntity = await InboundRecordRepository.InsertAsync(entity);
return ObjectMapper.Map<InboundRecord, InboundRecordOutput>(savedEntity);
}

View File

@ -27,6 +27,7 @@ public partial class OrderService
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync(orderIds);
var declarations = await OrderRepository.GetDeclarationsByOrderIdsAsync(orderIds);
var weights = await OrderRepository.GetWeightsByOrderIdsAsync(orderIds);
var fees = await OrderRepository.GetFeesByOrderIdsAsync(orderIds);
var warehouses = warehouseIds.Length == 0
? []
: await WarehouseAddressRepository.GetListAsync(x => warehouseIds.Contains(x.Id));
@ -35,6 +36,7 @@ public partial class OrderService
var recipientMap = recipients.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.First());
var declarationMap = declarations.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.ToList());
var weightMap = weights.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.ToList());
var feeMap = fees.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.ToList());
var warehouseMap = warehouses.ToDictionary(x => x.Id);
return orders.Select(order =>
@ -43,6 +45,12 @@ public partial class OrderService
recipientMap.TryGetValue(order.Id, out var recipient);
declarationMap.TryGetValue(order.Id, out var items);
weightMap.TryGetValue(order.Id, out var orderWeights);
feeMap.TryGetValue(order.Id, out var orderFees);
var feeOutputs = (orderFees ?? []).Select(MapFee).ToList();
var chargeableWeight = ResolveDisplayChargeableWeight(orderWeights);
var receivableAmount = feeOutputs
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
.Sum(x => x.Amount);
return new OrderOutput
{
Id = order.Id,
@ -75,7 +83,10 @@ public partial class OrderService
Sender = MapSender(sender),
Receiver = MapReceiver(recipient),
Items = (items ?? []).Select(MapDeclaration).ToList(),
WeightList = (orderWeights ?? []).Select(MapWeight).ToList()
WeightList = (orderWeights ?? []).Select(MapWeight).ToList(),
FeeList = feeOutputs,
ChargeableWeight = chargeableWeight,
ReceivableAmount = feeOutputs.Count == 0 ? null : receivableAmount
};
}).ToList();
}
@ -543,6 +554,37 @@ public partial class OrderService
};
}
private static OrderFeeOutput MapFee(OrderFee x) =>
new()
{
Id = x.Id,
Type = x.Type,
CostItem = x.CostItem,
Amount = x.Amount,
Currency = x.Currency,
DownstreamProvider = x.DownstreamProvider,
PriceNo = x.PriceNo,
PriceEffectiveTime = x.PriceEffectiveTime,
ChargingStage = x.ChargingStage
};
private static decimal? ResolveDisplayChargeableWeight(IReadOnlyList<OrderWeight>? weights)
{
if (weights == null || weights.Count == 0)
{
return null;
}
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
if (receive?.ChargeableWeight is > 0)
{
return receive.ChargeableWeight;
}
var forecast = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Forecast);
return forecast?.ChargeableWeight;
}
private static OrderTaskFail BuildTaskFail(int taskId, int rowNumber, string reason, object rawData)
{
return new OrderTaskFail

View File

@ -0,0 +1,648 @@
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Services.Orders;
public partial class OrderService
{
private IServiceChannelRepository ServiceChannelRepository =>
LazyServiceProvider.GetRequiredService<IServiceChannelRepository>();
/// <summary>
/// 编辑订单详情(基本信息、发件人、收件人)。
/// </summary>
[HttpPost("/order/update")]
public async Task<OrderOutput> UpdateOrderDetailAsync(OrderUpdateInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (input.ClientNo != null)
{
order.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim();
}
if (input.TrackNo != null)
{
order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim();
}
if (input.ChannelCode != null)
{
order.ChannelCode = string.IsNullOrWhiteSpace(input.ChannelCode) ? null : input.ChannelCode.Trim();
}
if (input.CargoAttribute != null)
{
order.CargoAttribute = input.CargoAttribute.Trim();
}
if (input.WarehouseId.HasValue)
{
order.WarehouseId = input.WarehouseId.Value > 0 ? input.WarehouseId : null;
}
if (input.IsCod.HasValue)
{
order.IsCod = input.IsCod.Value ? (byte)1 : (byte)0;
}
if (input.CodCurrency != null)
{
order.CodCurrency = input.CodCurrency;
}
if (input.CodSum.HasValue)
{
order.CodSum = input.CodSum;
}
if (input.DeclaredCurrency != null)
{
order.DeclaredCurrency = input.DeclaredCurrency;
}
if (input.DeclaredSum.HasValue)
{
order.DeclaredSum = input.DeclaredSum;
}
if (input.Quantity.HasValue)
{
order.Quantity = input.Quantity;
}
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
if (input.Sender != null && order.SenderId.HasValue)
{
var senders = await OrderRepository.GetSendersByOrderIdsAsync([order.Id]);
var sender = senders.FirstOrDefault();
if (sender != null)
{
ApplySenderInput(sender, input.Sender);
if (input.Sender.CountryCode != null)
{
var country = await GetCountryOrThrowAsync(input.Sender.CountryCode, "发件国家不存在");
sender.Country = country.Id;
}
await OrderRepository.UpdateSenderAsync(sender);
}
}
if (input.Receiver != null && order.RecipientId.HasValue)
{
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
if (recipient != null)
{
await ApplyReceiverInputAsync(order, recipient, input.Receiver);
await OrderRepository.UpdateRecipientAsync(recipient);
}
}
if (input.Items != null)
{
await OrderRepository.ReplaceDeclarationsAsync(
order.Id,
input.Items.Select(x => MapDeclarationInput(order.Id, x)));
}
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 编辑订单件重尺并重算计费重。
/// </summary>
[HttpPost("/order/weight/update")]
public async Task<OrderOutput> UpdateOrderWeightsAsync(OrderWeightUpdateInput input)
{
if (input.Weights.Count == 0)
{
throw new BusinessException("件重尺不能为空");
}
var order = await GetOrderOrThrowAsync(input.Id);
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法重算计费重");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("销售产品不存在");
}
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
.OrderBy(x => x.Id)
.ToList();
if (weights.Count == 0)
{
throw new BusinessException("订单件重尺不存在");
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var updatedTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in input.Weights)
{
var type = item.Type?.Trim();
if (string.IsNullOrWhiteSpace(type))
{
continue;
}
var row = weights.FirstOrDefault(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase));
if (row == null)
{
throw new BusinessException($"缺少件重尺类型 {type}");
}
if (item.Length.HasValue)
{
row.Length = item.Length;
}
if (item.Width.HasValue)
{
row.Width = item.Width;
}
if (item.Height.HasValue)
{
row.Height = item.Height;
}
if (item.ActualWeight.HasValue)
{
row.ActualWeight = item.ActualWeight;
}
if (row.Length.HasValue && row.Width.HasValue && row.Height.HasValue)
{
row.Cubic = ParcelBillingCalculator.CalculateCubic(row.Length.Value, row.Width.Value, row.Height.Value);
}
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, row);
updatedTypes.Add(type);
}
if (updatedTypes.Contains(OrderWeightTypeCodes.Forecast))
{
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
SyncWeightRows(weights, forecast);
}
else if (updatedTypes.Contains(OrderWeightTypeCodes.Receive))
{
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
if (receive != null)
{
SyncReceiveToShipAndDownstream(weights, receive);
}
}
else if (updatedTypes.Contains(OrderWeightTypeCodes.Ship))
{
var ship = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Ship);
if (ship != null)
{
SyncShipToDownstream(weights, ship);
}
}
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 重算订单应收费用。
/// </summary>
[HttpPost("/order/fee/recalculate-receivable")]
public async Task<OrderOutput> RecalculateReceivableFeesAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
await RecalculateReceivableCoreAsync(order);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 重算订单应付费用。
/// </summary>
[HttpPost("/order/fee/recalculate-payable")]
public async Task<OrderOutput> RecalculatePayableFeesAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
await RecalculatePayableCoreAsync(order);
return (await MapOrderOutputsAsync([order])).First();
}
private async Task RecalculateReceivableCoreAsync(Order order)
{
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法重算应收");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("销售产品不存在");
}
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
var (weightType, chargingStage) = ResolveReceivableBillingStage(order);
var weightRow = weights.FirstOrDefault(x => x.Type == weightType)
?? throw new BusinessException($"缺少件重尺类型 {weightType}");
if (!weightRow.ChargeableWeight.HasValue || weightRow.ChargeableWeight <= 0)
{
throw new BusinessException("计费重无效,请先完善件重尺");
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
recipient?.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
weightRow.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
chargingStage,
replaceExistingReceivable: true);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private async Task RecalculatePayableCoreAsync(Order order)
{
if (string.IsNullOrWhiteSpace(order.ChannelCode))
{
throw new BusinessException("订单未绑定服务渠道,无法重算应付");
}
var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim());
if (channel == null)
{
throw new BusinessException("服务渠道不存在");
}
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
var downstream = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream)
?? throw new BusinessException("缺少 downstream 件重尺");
if (!downstream.ChargeableWeight.HasValue || downstream.ChargeableWeight <= 0)
{
throw new BusinessException("downstream 计费重无效,请先完善件重尺");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法匹配分区");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
throw new BusinessException("销售产品不存在");
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault();
var zoneKeys = await ResolvePriceZoneKeysAsync(
product,
snapshotBinding.Rules,
snapshotBinding.Price,
order.ConsigneeCountry ?? string.Empty,
recipient?.Province,
recipient?.City,
recipient?.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var channelPrices = await BuildChannelPriceOutputsAsync(channel.Id);
var (_, chargingStage) = ResolveReceivableBillingStage(order);
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
var fees = ParcelBillingCalculator.CalculatePayableFees(
channelPrices,
$"LIVE-{channel.Id}",
order.CreateTime == default ? DateTime.Now : order.CreateTime,
downstream.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
chargingStage);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledPayablePriceItems(channelPrices))
{
throw new BusinessException("渠道买价未配置或未匹配到有效价格");
}
if (fees.Count > 0)
{
await OrderRepository.DeletePayableFeesAsync(order.Id);
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private async Task<List<ServiceChannelPriceListOutput>> BuildChannelPriceOutputsAsync(int serviceChannelId)
{
var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId);
var priceIds = prices.Select(x => x.Id).ToArray();
var factorMap = (await ServiceChannelRepository.GetPriceFactorsAsync(priceIds))
.GroupBy(x => x.PriceId)
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
var detailMap = (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds))
.GroupBy(x => x.PriceId)
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
return prices.Select(price => new ServiceChannelPriceListOutput
{
Id = price.Id,
ServiceChannelId = price.ServiceChannelId,
SerialNumber = price.SerialNumber,
Status = (byte)price.Status,
PriceName = price.PriceName,
CostItem = price.CostItem,
CostUnit = price.CostUnit,
Currency = price.Currency,
ChargingCondition = price.ChargingCondition,
Factors = factorMap.TryGetValue(price.Id, out var factors)
? factors.Select(MapChannelPriceFactorOutput).ToList()
: [],
Details = detailMap.TryGetValue(price.Id, out var details)
? details.Select(MapChannelPriceDetailOutput).ToList()
: []
}).ToList();
}
private static ChannelPriceFactorOutput MapChannelPriceFactorOutput(ServiceChannelPriceFactor entity) =>
new()
{
Id = entity.Id,
PriceId = entity.PriceId,
SerialNumber = entity.SerialNumber,
Factor = entity.Factor,
FactorValues = OmsDocHelper.DeserializeJson<List<string>>(entity.FactorValues) ?? []
};
private static ChannelPriceDetailOutput MapChannelPriceDetailOutput(ServiceChannelPriceDetail entity) =>
new()
{
Id = entity.Id,
PriceId = entity.PriceId,
FactorValuesJson = OmsDocHelper.DeserializeJsonObject(entity.FactorValuesJson),
CalculationFormula = entity.CalculationFormula,
CalculatedValue = entity.CalculatedValue,
CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(entity.CustomerGroupValues)
};
private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) =>
order.OrderStatus switch
{
OrderStatusCodes.Received or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip
=> (OrderWeightTypeCodes.Receive, OrderFeeChargingStageCodes.Inbound),
OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed
=> (OrderWeightTypeCodes.Ship, OrderFeeChargingStageCodes.Outbound),
_ => (OrderWeightTypeCodes.Forecast, OrderFeeChargingStageCodes.Forecast)
};
private static OrderDeclaration MapDeclarationInput(int orderId, OrderParcelItemInput x)
{
return new OrderDeclaration
{
OrderId = orderId,
CnName = x.CnName,
EnName = x.EnName,
Sku = x.Sku,
Unit = x.Unit,
UnitPrice = x.UnitPrice,
UnitWeight = x.UnitWeight,
Quantity = x.Quantity,
Amount = x.Amount,
Weight = x.Weight,
HsCode = x.ExHsCode ?? x.ImHsCode,
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.ImHsCode,
SkuImage = x.SkuImage,
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0,
Distribution = x.Distribution,
Material = x.Material,
EnMaterial = x.EnMaterial,
Purpose = x.Purpose,
Attributes = x.Attributes,
OriginCountry = x.OriginCountry,
SalesLink = x.SalesLink,
Model = x.Model,
Specifications = x.Specifications,
Comment = x.Comment
};
}
private static void ApplySenderInput(OrderSender sender, OrderParcelSenderInput input)
{
if (input.SenderName != null)
{
sender.SenderName = input.SenderName;
}
if (input.Company != null)
{
sender.Company = input.Company;
}
if (input.Phone != null)
{
sender.Phone = input.Phone;
}
if (input.TelePhone != null)
{
sender.TelePhone = input.TelePhone;
}
if (input.Email != null)
{
sender.Email = input.Email;
}
if (input.Province != null)
{
sender.Province = input.Province;
}
if (input.City != null)
{
sender.City = input.City;
}
if (input.ZipCode != null)
{
sender.ZipCode = input.ZipCode;
}
if (input.StreetAddress1 != null)
{
sender.StreetAddress1 = input.StreetAddress1;
}
if (input.StreetAddress2 != null)
{
sender.StreetAddress2 = input.StreetAddress2;
}
if (input.StreetAddress3 != null)
{
sender.StreetAddress3 = input.StreetAddress3;
}
if (input.HouseNumber != null)
{
sender.HouseNumber = input.HouseNumber;
}
}
private async Task ApplyReceiverInputAsync(Order order, OrderRecipient recipient, OrderParcelReceiverInput input)
{
if (input.RecipientsName != null)
{
recipient.RecipientsName = input.RecipientsName;
}
if (input.CompanyName != null)
{
recipient.CompanyName = input.CompanyName;
}
if (input.Phone != null)
{
recipient.Phone = input.Phone;
}
if (input.TelePhone != null)
{
recipient.TelePhone = input.TelePhone;
}
if (input.Email != null)
{
recipient.Email = input.Email;
}
if (input.Province != null)
{
recipient.Province = input.Province;
}
if (input.City != null)
{
recipient.City = input.City;
}
if (input.StreetNumber != null)
{
recipient.StreetNumber = input.StreetNumber;
}
if (input.StreetAddress1 != null)
{
recipient.StreetAddress1 = input.StreetAddress1;
}
if (input.StreetAddress2 != null)
{
recipient.StreetAddress2 = input.StreetAddress2;
}
if (input.StreetAddress3 != null)
{
recipient.StreetAddress3 = input.StreetAddress3;
}
if (input.ZipCode != null)
{
recipient.ZipCode = input.ZipCode;
}
if (input.TaxType.HasValue)
{
recipient.TaxType = input.TaxType;
}
if (input.TaxNo != null)
{
recipient.TaxNo = input.TaxNo;
}
if (input.CertificateType.HasValue)
{
recipient.CertificateType = input.CertificateType;
}
if (input.CertificateNo != null)
{
recipient.CertificateNo = input.CertificateNo;
}
if (input.Eori != null)
{
recipient.Eori = input.Eori;
}
if (input.Vat != null)
{
recipient.Vat = input.Vat;
}
if (input.CountryCode != null)
{
var country = await GetCountryOrThrowAsync(input.CountryCode, "收件国家不存在");
recipient.Country = country.Id;
order.ConsigneeCountry = country.CountryCode;
await OrderRepository.UpdateAsync(order);
}
}
}

View File

@ -366,7 +366,8 @@ public partial class OrderService
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage)
string chargingStage,
bool replaceExistingReceivable = false)
{
var fees = ParcelBillingCalculator.CalculateReceivableFees(
snapshotBinding.Price,
@ -385,6 +386,11 @@ public partial class OrderService
if (fees.Count > 0)
{
if (replaceExistingReceivable)
{
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
}
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
}
@ -857,6 +863,23 @@ public partial class OrderService
}
}
private static void SyncShipToDownstream(List<OrderWeight> weights, OrderWeight ship)
{
var row = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream);
if (row == null)
{
return;
}
row.Length = ship.Length;
row.Width = ship.Width;
row.Height = ship.Height;
row.ActualWeight = ship.ActualWeight;
row.VolumetricWeight = ship.VolumetricWeight;
row.ChargeableWeight = ship.ChargeableWeight;
row.Cubic = ship.Cubic;
}
private static Order BuildDraftOrderEntity(
OrderParcelCreateInput input,
SalesProduct product,
@ -1028,7 +1051,7 @@ public partial class OrderService
}
/// <summary>
/// 入库收货后:覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并同步 ship/downstream 重量行。
/// 入库收货后:覆盖 receive 件重尺、重算计费重,覆盖写入 inbound 阶段应收,并同步 ship/downstream 重量行。
/// </summary>
internal async Task ApplyReceiveBillingByWaybillAsync(
string waybillNo,
@ -1039,30 +1062,35 @@ public partial class OrderService
{
if (string.IsNullOrWhiteSpace(waybillNo))
{
return;
throw new BusinessException("运单号不能为空");
}
if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue
|| length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0)
{
return;
throw new BusinessException("收货实重与长宽高不能为空且须大于 0");
}
var order = await OrderRepository.GetByServiceNoAsync(waybillNo.Trim());
if (order == null || string.IsNullOrWhiteSpace(order.ProductCode))
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
if (order == null)
{
return;
throw new BusinessException("未找到对应订单,请检查单号");
}
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
throw new BusinessException("订单未绑定销售产品,无法入库计费");
}
if (order.OrderStatus is not (OrderStatusCodes.Predeclared or OrderStatusCodes.Received))
{
return;
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许入库收货");
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
return;
throw new BusinessException("订单关联的销售产品不存在");
}
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
@ -1071,7 +1099,7 @@ public partial class OrderService
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
if (receive == null)
{
return;
throw new BusinessException("订单缺少收货件重尺记录");
}
receive.Length = length;
@ -1080,25 +1108,8 @@ public partial class OrderService
receive.ActualWeight = actualWeight;
receive.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value);
ProductSnapshotBinding snapshotBinding;
try
{
snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
}
catch (BusinessException)
{
return;
}
try
{
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
}
catch (BusinessException)
{
return;
}
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
SyncReceiveToShipAndDownstream(weights, receive);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
@ -1114,28 +1125,22 @@ public partial class OrderService
recipient?.ZipCode);
if (zoneKeys == null || zoneKeys.Count == 0)
{
return;
throw new BusinessException("价格分区未匹配(规则快照)");
}
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
try
{
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
receive.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Inbound);
}
catch (BusinessException)
{
return;
}
await UpsertReceivableFeesAsync(
order,
snapshotBinding,
receive.ChargeableWeight ?? 0,
zoneKeys,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup,
OrderFeeChargingStageCodes.Inbound,
replaceExistingReceivable: true);
order.OrderStatus = OrderStatusCodes.Received;
order.ReceiveTime = DateTime.Now;

View File

@ -11,6 +11,8 @@ public interface IOrderRepository : IRepository<Order>
Task<Order?> GetByTrackNoAsync(string trackNo, CancellationToken cancellationToken = default);
Task<Order?> GetByWaybillNoAsync(string waybillNo, CancellationToken cancellationToken = default);
Task<Order?> GetByCustomerAndClientNoAsync(int customerId, string clientNo, CancellationToken cancellationToken = default);
Task<Order?> GetByRequestIdAsync(string requestId, CancellationToken cancellationToken = default);
@ -29,11 +31,21 @@ public interface IOrderRepository : IRepository<Order>
Task InsertDeclarationsAsync(IEnumerable<OrderDeclaration> declarations, CancellationToken cancellationToken = default);
Task ReplaceDeclarationsAsync(int orderId, IEnumerable<OrderDeclaration> declarations, CancellationToken cancellationToken = default);
Task InsertWeightsAsync(IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default);
Task ReplaceWeightsAsync(int orderId, IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default);
Task UpsertFeesAsync(int orderId, IEnumerable<OrderFee> fees, CancellationToken cancellationToken = default);
Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default);
Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default);
Task UpdateSenderAsync(OrderSender sender, CancellationToken cancellationToken = default);
Task UpdateRecipientAsync(OrderRecipient recipient, CancellationToken cancellationToken = default);
Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default);
}

View File

@ -1,4 +1,5 @@
using Fake.DependencyInjection;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
@ -22,6 +23,29 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
return FirstOrDefaultAsync(x => x.TrackNo == trackNo, cancellationToken);
}
public async Task<Order?> GetByWaybillNoAsync(string waybillNo, CancellationToken cancellationToken = default)
{
var normalized = waybillNo.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
var order = await GetByServiceNoAsync(normalized, cancellationToken);
if (order != null)
{
return order;
}
order = await GetByTrackNoAsync(normalized, cancellationToken);
if (order != null)
{
return order;
}
return await FirstOrDefaultAsync(x => x.ClientNo == normalized, cancellationToken);
}
public Task<Order?> GetByCustomerAndClientNoAsync(int customerId, string clientNo, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.CustomerId == customerId && x.ClientNo == clientNo, cancellationToken);
@ -112,6 +136,17 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
await db.Insertable(items).ExecuteCommandAsync(cancellationToken);
}
public async Task ReplaceDeclarationsAsync(int orderId, IEnumerable<OrderDeclaration> declarations, CancellationToken cancellationToken = default)
{
var items = declarations.ToArray();
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderDeclaration>().Where(x => x.OrderId == orderId).ExecuteCommandAsync(cancellationToken);
if (items.Length > 0)
{
await db.Insertable(items).ExecuteCommandAsync(cancellationToken);
}
}
public async Task InsertWeightsAsync(IEnumerable<OrderWeight> weights, CancellationToken cancellationToken = default)
{
var items = weights.ToArray();
@ -171,6 +206,36 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
}
}
public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Receivable)
.ExecuteCommandAsync(cancellationToken);
}
public async Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
await db.Deleteable<OrderFee>()
.Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Payable)
.ExecuteCommandAsync(cancellationToken);
}
public async Task UpdateSenderAsync(OrderSender sender, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
sender.UpdateTime = DateTime.Now;
await db.Updateable(sender).ExecuteCommandAsync(cancellationToken);
}
public async Task UpdateRecipientAsync(OrderRecipient recipient, CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
recipient.UpdateTime = DateTime.Now;
await db.Updateable(recipient).ExecuteCommandAsync(cancellationToken);
}
public async Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default)
{
var ids = orderIds.Distinct().ToArray();