admin/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
zzh d8752959e7 feat(order): 强化小包下单规则并暴露预报失败原因
客户单号必填且同客户唯一,运单号统一 NE 前缀自动生成;模板导入不再接受跟踪号/运单号;列表与导出返回预报失败原因字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 09:44:12 +08:00

698 lines
23 KiB
C#

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)
{
var newClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim();
if (!string.IsNullOrWhiteSpace(newClientNo)
&& !string.Equals(newClientNo, order.ClientNo, StringComparison.OrdinalIgnoreCase)
&& order.CustomerId.HasValue)
{
var duplicated = await OrderRepository.GetByCustomerAndClientNoAsync(order.CustomerId.Value, newClientNo);
if (duplicated != null && duplicated.Id != order.Id)
{
throw new BusinessException("客户单号已存在");
}
}
order.ClientNo = newClientNo;
}
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);
}
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();
}
/// <summary>
/// 手动添加订单费用(应收或应付)。
/// </summary>
[HttpPost("/order/fee/add")]
public async Task<OrderOutput> AddOrderFeeAsync(OrderFeeAddInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
var type = input.Type?.Trim().ToLowerInvariant() ?? string.Empty;
if (type is not (OrderFeeTypeCodes.Receivable or OrderFeeTypeCodes.Payable))
{
throw new BusinessException("费用类型无效");
}
var costItem = input.CostItem?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(costItem))
{
throw new BusinessException("费用项不能为空");
}
if (input.Amount <= 0)
{
throw new BusinessException("金额必须大于0");
}
var currency = input.Currency?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(currency))
{
throw new BusinessException("币种不能为空");
}
var chargingStage = input.ChargingStage?.Trim() ?? string.Empty;
if (chargingStage is not (
OrderFeeChargingStageCodes.Forecast
or OrderFeeChargingStageCodes.Inbound
or OrderFeeChargingStageCodes.Outbound))
{
throw new BusinessException("计费阶段无效");
}
var fee = new OrderFee
{
Type = type,
CostItem = costItem,
Amount = input.Amount,
Currency = currency,
ChargingStage = chargingStage,
DownstreamProvider = string.IsNullOrWhiteSpace(input.DownstreamProvider)
? null
: input.DownstreamProvider.Trim(),
PriceNo = string.IsNullOrWhiteSpace(input.PriceNo) ? null : input.PriceNo.Trim(),
PriceEffectiveTime = input.PriceEffectiveTime,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
};
await OrderRepository.UpsertFeesAsync(order.Id, [fee]);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(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.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 weightRow = ResolvePayableWeightRow(order, weights);
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 (_, chargingStage) = ResolveReceivableBillingStage(order);
var customer = order.CustomerId.HasValue
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
: null;
await ConfirmServiceChannelAndPayableAsync(
order,
product,
snapshotBinding,
zoneKeys,
weightRow.ChargeableWeight ?? 0,
chargingStage,
customer?.CustomerGroup);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private static OrderWeight ResolvePayableWeightRow(Order order, IReadOnlyList<OrderWeight> weights)
{
var (_, chargingStage) = ResolveReceivableBillingStage(order);
var weightType = chargingStage switch
{
OrderFeeChargingStageCodes.Inbound => OrderWeightTypeCodes.Receive,
_ => OrderWeightTypeCodes.Downstream
};
return weights.FirstOrDefault(x => x.Type == weightType)
?? weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream)
?? throw new BusinessException($"缺少件重尺类型 {weightType}");
}
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;
order.ConsigneeCountryName = country.ChineseName;
await OrderRepository.UpdateAsync(order);
}
}
}