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>
998 lines
34 KiB
C#
998 lines
34 KiB
C#
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;
|
|
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);
|
|
EnsureReceivableMutable(order);
|
|
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>
|
|
/// 重跑入库计费(应收 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>
|
|
[HttpPost("/order/additional-services/update")]
|
|
public async Task<OrderOutput> UpdateAdditionalServicesAsync(OrderAdditionalServicesUpdateInput input)
|
|
{
|
|
var order = await GetOrderOrThrowAsync(input.Id);
|
|
EnsureReceivableMutable(order);
|
|
|
|
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
|
{
|
|
throw new BusinessException("订单未绑定销售产品,无法更新附加服务");
|
|
}
|
|
|
|
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
|
if (product == null)
|
|
{
|
|
throw new BusinessException("销售产品不存在");
|
|
}
|
|
|
|
EnsureAdditionalServicesAllowed(product, input.AdditionalServices);
|
|
order.AdditionalServices = SerializeAdditionalServices(input.AdditionalServices);
|
|
|
|
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 billing = await ResolveOrderBillingContextAsync(
|
|
order,
|
|
product,
|
|
weightRow.ChargeableWeight ?? 0,
|
|
snapshotBinding);
|
|
|
|
if (chargingStage == OrderFeeChargingStageCodes.Inbound)
|
|
{
|
|
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
|
|
await UpsertReceivableFeesAsync(order, billing, chargingStage);
|
|
}
|
|
else
|
|
{
|
|
await UpsertReceivableFeesAsync(
|
|
order,
|
|
billing,
|
|
chargingStage,
|
|
replaceExistingReceivable: true);
|
|
}
|
|
|
|
await RefreshMarketingPayableAmountAsync(order);
|
|
await TryConfirmServiceChannelAndPayableAsync(order, product, billing, chargingStage);
|
|
|
|
order.UpdateTime = DateTime.Now;
|
|
await OrderRepository.UpdateAsync(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("费用类型无效");
|
|
}
|
|
|
|
if (type == OrderFeeTypeCodes.Receivable)
|
|
{
|
|
EnsureReceivableMutable(order);
|
|
}
|
|
|
|
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 billing = await ResolveOrderBillingContextAsync(
|
|
order,
|
|
product,
|
|
weightRow.ChargeableWeight ?? 0,
|
|
snapshotBinding);
|
|
|
|
await UpsertReceivableFeesAsync(
|
|
order,
|
|
billing,
|
|
chargingStage,
|
|
replaceExistingReceivable: true);
|
|
|
|
order.UpdateTime = DateTime.Now;
|
|
await OrderRepository.UpdateAsync(order);
|
|
}
|
|
|
|
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("订单未绑定销售产品,无法重算应付");
|
|
}
|
|
|
|
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 billing = await ResolveOrderBillingContextAsync(
|
|
order,
|
|
product,
|
|
weightRow.ChargeableWeight ?? 0,
|
|
snapshotBinding);
|
|
|
|
var (_, chargingStage) = ResolveReceivableBillingStage(order);
|
|
|
|
await ConfirmServiceChannelAndPayableAsync(
|
|
order,
|
|
product,
|
|
billing,
|
|
chargingStage,
|
|
schemeOverride,
|
|
forceChannelId);
|
|
|
|
if (string.Equals(order.OrderStatus, OrderStatusCodes.PendingAllocation, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
order.OrderStatus = OrderStatusCodes.PendingPayment;
|
|
}
|
|
|
|
order.UpdateTime = DateTime.Now;
|
|
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);
|
|
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.PendingAllocation 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);
|
|
}
|
|
}
|
|
}
|