admin/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs
zzh 8ce8b82f48
All checks were successful
Build and Deploy / deploy (push) Successful in 1m49s
feat: 客户出口资质备案与申报要素,9610 预报直连下游
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:13:55 +08:00

1315 lines
46 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 Newtonsoft.Json;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Dtos.Shared;
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);
EnsureReceivableMutable(order);
await ProcessInboundBillingForBatchAsync(order.Id);
order = await GetOrderOrThrowAsync(input.Id);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 下载对账件重尺(downstream)导入模板。导入后只重算应付,不改应收。
/// </summary>
[HttpGet("/order/weight/downstream-import-template")]
public async Task<FileContentResult> DownloadDownstreamWeightImportTemplateAsync()
{
var bytes = await OrderDownstreamWeightImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = OrderDownstreamWeightImportHelper.TemplateFileName
};
}
/// <summary>
/// 导入对账件重尺:写入 Type=downstream,锁定当前渠道重算应付;不改应收。
/// </summary>
[HttpPost("/order/weight/downstream-import")]
public async Task<OrderDownstreamWeightImportOutput> ImportDownstreamWeightsAsync(
[FromForm] OrderDownstreamWeightImportInput input)
{
if (input.File == null || input.File.Length == 0)
{
throw new BusinessException("请上传 Excel 文件");
}
await using var stream = input.File.OpenReadStream();
List<(int RowNumber, OrderDownstreamWeightImportRow Row)> parsedRows;
try
{
parsedRows = await OrderDownstreamWeightImportHelper.ParseImportRowsAsync(stream);
}
catch (Exception ex)
{
throw new BusinessException($"Excel 解析失败:{ex.Message}");
}
if (parsedRows.Count == 0)
{
throw new BusinessException("Excel 中无有效数据行");
}
if (parsedRows.Count > OrderDownstreamWeightImportHelper.MaxRows)
{
throw new BusinessException(
$"单次最多导入 {OrderDownstreamWeightImportHelper.MaxRows} 行,当前 {parsedRows.Count} 行");
}
var output = new OrderDownstreamWeightImportOutput();
foreach (var (rowNumber, row) in parsedRows)
{
try
{
OrderDownstreamWeightImportHelper.ValidateRow(row);
await ApplyDownstreamWeightAndRecalculatePayableAsync(row);
output.SuccessCount++;
}
catch (Exception ex)
{
output.FailCount++;
output.Failures.Add(new OrderDownstreamWeightImportFailRow
{
RowNumber = rowNumber,
WaybillNo = row.WaybillNo,
Reason = ex is BusinessException or InvalidOperationException
? ex.Message
: $"导入失败:{ex.Message}"
});
}
}
return output;
}
/// <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 ResolveSnapshotBindingAsync(product, DateTime.Now);
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-channel-options")]
public async Task<OmsItemsResult<OrderReallocateChannelOptionOutput>> GetReallocateChannelOptionsAsync(
[FromBody] OrderReallocateChannelOptionsInput input)
{
var orderIds = (input.OrderIds ?? []).Where(x => x > 0).Distinct().ToList();
if (orderIds.Count == 0)
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
if (orders.Count == 0)
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
HashSet<int>? intersection = null;
var channelMeta = new Dictionary<int, (string Code, string? Name)>();
foreach (var order in orders)
{
if (string.IsNullOrWhiteSpace(order.ProductCode))
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
if (product == null)
{
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = [] };
}
var binding = await ResolveSnapshotBindingAsync(product, DateTime.Now);
var providers = await ResolveAllocationProvidersAsync(product, binding.Rules);
var ruleContext = await OrderAutoTriggerService.BuildPostCommitContextAsync(order);
var filtered = AllocationResolver.FilterProvidersDetailed(
providers,
order.CustomerId,
ruleContext).Passed;
var channelIds = new HashSet<int>();
foreach (var provider in filtered)
{
if (provider.ServiceChannelId is not > 0)
{
continue;
}
var channelId = provider.ServiceChannelId.Value;
if (!channelMeta.TryGetValue(channelId, out _))
{
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
if (channel == null || channel.IsActive != EnableStatus.Enabled)
{
continue;
}
channelMeta[channelId] = (channel.ChannelCode, channel.ChannelName);
}
channelIds.Add(channelId);
}
intersection = intersection == null
? channelIds
: intersection.Intersect(channelIds).ToHashSet();
if (intersection.Count == 0)
{
break;
}
}
var items = (intersection ?? [])
.Where(id => channelMeta.ContainsKey(id))
.Select(id => new OrderReallocateChannelOptionOutput
{
ServiceChannelId = id,
ChannelCode = channelMeta[id].Code,
ChannelName = channelMeta[id].Name
})
.OrderBy(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase)
.ToList();
return new OmsItemsResult<OrderReallocateChannelOptionOutput> { Items = items };
}
/// <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);
await RebillReceivableAfterAdditionalServicesChangeAsync(order, product);
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return (await MapOrderOutputsAsync([order])).First();
}
/// <summary>
/// 附加服务变更后重算当前阶段应收(订单须未付款)。
/// </summary>
private async Task RebillReceivableAfterAdditionalServicesChangeAsync(Order order, SalesProduct product)
{
EnsureReceivableMutable(order);
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);
}
/// <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,
lockCurrentChannel: true);
}
/// <summary>
/// 写入对账件重尺并锁定当前渠道重算应付(不改应收)。
/// </summary>
private async Task ApplyDownstreamWeightAndRecalculatePayableAsync(OrderDownstreamWeightImportRow row)
{
var order = await OrderRepository.GetByWaybillNoAsync(row.WaybillNo!.Trim());
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查单号");
}
if (string.IsNullOrWhiteSpace(order.ChannelCode))
{
throw new BusinessException("订单未确认服务渠道,无法按对账重量重算应付");
}
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();
var downstream = weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Downstream, StringComparison.OrdinalIgnoreCase));
if (downstream == null)
{
throw new BusinessException("订单缺少下游件重尺记录");
}
// 尺寸可选:未填则沿用下游已有,仍缺则回落收货→发货→预报。
var length = row.Length ?? downstream.Length;
var width = row.Width ?? downstream.Width;
var height = row.Height ?? downstream.Height;
if (length is not > 0 || width is not > 0 || height is not > 0)
{
FillDimensionsFromFallback(weights, ref length, ref width, ref height);
}
if (length is not > 0 || width is not > 0 || height is not > 0)
{
throw new BusinessException("件重尺尺寸不完整,请在 Excel 填写长/宽/高,或先完善订单收货/发货尺寸");
}
ApplyMeasuredDimensions(downstream, length.Value, width.Value, height.Value, row.ActualWeight!.Value);
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, downstream);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
await ReallocateCoreAsync(
order,
scheme: null,
serviceChannelId: null,
enforceReallocateGate: false,
lockCurrentChannel: true,
preferDownstreamWeight: true);
}
private static void FillDimensionsFromFallback(
IReadOnlyList<OrderWeight> weights,
ref decimal? length,
ref decimal? width,
ref decimal? height)
{
foreach (var type in new[]
{
OrderWeightTypeCodes.Receive,
OrderWeightTypeCodes.Ship,
OrderWeightTypeCodes.Forecast
})
{
var src = weights.FirstOrDefault(x =>
string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase));
if (src == null)
{
continue;
}
if (length is not > 0)
{
length = src.Length;
}
if (width is not > 0)
{
width = src.Width;
}
if (height is not > 0)
{
height = src.Height;
}
if (length is > 0 && width is > 0 && height is > 0)
{
return;
}
}
}
/// <summary>
/// 重新排货核心:按方案/指定渠道确认服务渠道并重写应付。
/// </summary>
private async Task ReallocateCoreAsync(
Order order,
string? scheme,
int? serviceChannelId,
bool enforceReallocateGate = true,
bool lockCurrentChannel = false,
bool preferDownstreamWeight = false)
{
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;
}
else if (lockCurrentChannel && !string.IsNullOrWhiteSpace(order.ChannelCode))
{
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
x.ChannelCode == order.ChannelCode.Trim());
if (channel == null)
{
throw new BusinessException("订单服务渠道不存在,无法重算应付");
}
forceChannelId = channel.Id;
schemeOverride = AllocationSchemeCodes.Designated;
}
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 = preferDownstreamWeight
? weights.FirstOrDefault(x =>
string.Equals(x.Type, OrderWeightTypeCodes.Downstream, StringComparison.OrdinalIgnoreCase))
?? throw new BusinessException("缺少件重尺类型 downstream")
: ResolvePayableWeightRow(order, weights);
if (!weightRow.ChargeableWeight.HasValue || weightRow.ChargeableWeight <= 0)
{
throw new BusinessException("计费重无效,请先完善件重尺");
}
// 重新排货:产品规则/分区与渠道买价均取当前有效发布,避免沿用下单时旧快照。
// 对账重算 / 普通重算应付:沿用订单快照绑定(分区等),买价仍由排货评估取当前有效发布。
var snapshotBinding = enforceReallocateGate
? await ResolveSnapshotBindingAsync(product, DateTime.Now)
: await ResolveSnapshotBindingFromOrderAsync(product, order);
var billing = await ResolveOrderBillingContextAsync(
order,
product,
weightRow.ChargeableWeight ?? 0,
snapshotBinding);
var (_, chargingStage) = ResolveReceivableBillingStage(order);
// 对账件重尺导入:费用阶段记 outbound(终稿成本),与入库应收阶段解耦。
if (preferDownstreamWeight)
{
chargingStage = OrderFeeChargingStageCodes.Outbound;
}
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,
OrderFeeChargingStageCodes.Outbound => OrderWeightTypeCodes.Ship,
_ => OrderWeightTypeCodes.Forecast
};
return weights.FirstOrDefault(x =>
string.Equals(x.Type, weightType, StringComparison.OrdinalIgnoreCase))
?? 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.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 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,
DeclareElement = x.DeclareElement,
SkuImage = FileStorage.ToStorageKey(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);
}
}
}