All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
支持按 box/sku 模板生成 PDF;待支付前可改产品并自动重排货重算费。 Co-authored-by: Cursor <cursoragent@cursor.com>
1683 lines
61 KiB
C#
1683 lines
61 KiB
C#
using System.Globalization;
|
||
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.Application.Storage;
|
||
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>
|
||
/// 编辑订单详情(基本信息、发件人、收件人)。
|
||
/// 跟踪号变更时同步调用轨迹 Upsert(不走 Outbox)。
|
||
/// </summary>
|
||
[HttpPost("/order/update")]
|
||
public async Task<OrderOutput> UpdateOrderDetailAsync(OrderUpdateInput input)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(input.Id);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true);
|
||
|
||
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;
|
||
}
|
||
|
||
var previousTrackNo = order.TrackNo;
|
||
if (input.TrackNo != null)
|
||
{
|
||
order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim();
|
||
}
|
||
|
||
if (input.DownstreamServiceNo != null)
|
||
{
|
||
order.DownstreamServiceNo = string.IsNullOrWhiteSpace(input.DownstreamServiceNo)
|
||
? null
|
||
: input.DownstreamServiceNo.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)));
|
||
}
|
||
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeParties: true);
|
||
// 前端保存基本信息时总会带 items;仅在无字段级 diff 时用备注兜底,避免掩盖真实字段变更
|
||
var fieldChanges = OrderOperationLogSnapshot.Diff(beforeSnap, afterSnap);
|
||
var remark = input.Items != null && fieldChanges.Count == 0 ? "申报信息已更新" : null;
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.EditBasic,
|
||
beforeSnap,
|
||
afterSnap,
|
||
remark);
|
||
|
||
if (input.TrackNo != null)
|
||
{
|
||
await SyncSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo);
|
||
}
|
||
|
||
return (await MapOrderOutputsAsync([order])).First();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 修改订单销售产品:未付款且草稿/已预报/待排货/待支付可改;
|
||
/// 有计费重时按新产品排货池自动重排货并重算应收/应付。
|
||
/// </summary>
|
||
/// <param name="input">入参:订单 ID、新销售产品编码。</param>
|
||
/// <returns>更新后的订单详情。</returns>
|
||
[HttpPost("/order/change-product")]
|
||
public async Task<OrderOutput> ChangeProductAsync(OrderChangeProductInput input)
|
||
{
|
||
var newProductCode = input.ProductCode?.Trim() ?? string.Empty;
|
||
if (string.IsNullOrWhiteSpace(newProductCode))
|
||
{
|
||
throw new BusinessException("销售产品编码不能为空");
|
||
}
|
||
|
||
var order = await GetOrderOrThrowAsync(input.Id);
|
||
EnsureChangeProductAllowed(order);
|
||
|
||
var oldProductCode = order.ProductCode?.Trim() ?? string.Empty;
|
||
if (string.Equals(oldProductCode, newProductCode, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return (await MapOrderOutputsAsync([order])).First();
|
||
}
|
||
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(newProductCode);
|
||
if (product == null || product.IsActive != EnableStatus.Enabled)
|
||
{
|
||
throw new BusinessException("销售产品不存在或未启用");
|
||
}
|
||
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
|
||
order.ProductCode = product.ProductCode;
|
||
order.ChannelCode = null;
|
||
order.LabelTemplateId = null;
|
||
order.AllocationPendingReason = null;
|
||
order.AllocationTraceJson = null;
|
||
order.AllocationTraceTime = null;
|
||
order.AllocationScheme = null;
|
||
order.DownstreamSupplierCode = null;
|
||
order.DownstreamSupplierName = null;
|
||
order.DownstreamAppKey = null;
|
||
order.DownstreamAppId = null;
|
||
order.ChannelForecastCode = null;
|
||
|
||
var selectedServices = NormalizeAdditionalServiceCodes(
|
||
OmsDocHelper.DeserializeJson<List<string>>(order.AdditionalServices));
|
||
var allowed = ParseAdditionalServiceCodes(product.AdditionalServices);
|
||
var kept = selectedServices.Where(code => allowed.Contains(code)).ToList();
|
||
order.AdditionalServices = SerializeAdditionalServices(kept);
|
||
|
||
var snapshotBinding = await ResolveSnapshotBindingAsync(product, DateTime.Now);
|
||
order.SalesProductRulesVersion = snapshotBinding.RulesVersion;
|
||
order.SalesProductPriceVersion = snapshotBinding.PriceVersion;
|
||
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
|
||
var hasChargeableWeight = TryGetPayableChargeableWeight(order, weights, out _);
|
||
if (hasChargeableWeight)
|
||
{
|
||
var isDraft = string.Equals(
|
||
order.OrderStatus,
|
||
OrderStatusCodes.Draft,
|
||
StringComparison.OrdinalIgnoreCase);
|
||
// 草稿不在「重新排货」闸门内;有计费重时仍按新产品默认方案确认渠道并重写应付。
|
||
await ReallocateCoreAsync(
|
||
order,
|
||
scheme: AllocationSchemeCodes.CostLowest,
|
||
serviceChannelId: null,
|
||
enforceReallocateGate: !isDraft);
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
EnsureReceivableMutable(order);
|
||
await RecalculateReceivableCoreAsync(order);
|
||
}
|
||
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
var remark = string.IsNullOrWhiteSpace(oldProductCode)
|
||
? $"销售产品 → {product.ProductCode}"
|
||
: $"销售产品 {oldProductCode} → {product.ProductCode}";
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.ChangeProduct,
|
||
beforeSnap,
|
||
afterSnap,
|
||
remark);
|
||
|
||
return (await MapOrderOutputsAsync([order])).First();
|
||
}
|
||
|
||
private static void EnsureChangeProductAllowed(Order order)
|
||
{
|
||
if (order.IsPaid != 0)
|
||
{
|
||
throw new BusinessException("订单已付款,不能修改销售产品");
|
||
}
|
||
|
||
if (order.OrderStatus is not (
|
||
OrderStatusCodes.Draft
|
||
or OrderStatusCodes.Predeclared
|
||
or OrderStatusCodes.PendingAllocation
|
||
or OrderStatusCodes.PendingPayment))
|
||
{
|
||
throw new BusinessException(
|
||
$"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅草稿/已预报/待排货/待支付可改销售产品");
|
||
}
|
||
}
|
||
|
||
private static bool TryGetPayableChargeableWeight(
|
||
Order order,
|
||
IReadOnlyList<OrderWeight> weights,
|
||
out decimal chargeable)
|
||
{
|
||
chargeable = 0;
|
||
try
|
||
{
|
||
var row = ResolvePayableWeightRow(order, weights);
|
||
if (!row.ChargeableWeight.HasValue || row.ChargeableWeight <= 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
chargeable = row.ChargeableWeight.Value;
|
||
return true;
|
||
}
|
||
catch (BusinessException)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
|
||
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);
|
||
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.EditWeight,
|
||
beforeSnap,
|
||
afterSnap);
|
||
|
||
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);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
EnsureReceivableMutable(order);
|
||
await RecalculateReceivableCoreAsync(order);
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.RecalcReceivable,
|
||
beforeSnap,
|
||
afterSnap);
|
||
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);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await RecalculatePayableCoreAsync(order);
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.RecalcPayable,
|
||
beforeSnap,
|
||
afterSnap);
|
||
return (await MapOrderOutputsAsync([order])).First();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 切换订单绑定的销售产品规则/价格发布版本(显式改契约后再重算)。
|
||
/// </summary>
|
||
[HttpPost("/order/fee/bind-snapshot-versions")]
|
||
public async Task<OrderOutput> BindSnapshotVersionsAsync([FromBody] OrderBindSnapshotVersionsInput input)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(input.Id);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
|
||
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
||
{
|
||
throw new BusinessException("订单未绑定销售产品,无法切换版本");
|
||
}
|
||
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||
if (product == null)
|
||
{
|
||
throw new BusinessException("销售产品不存在");
|
||
}
|
||
|
||
var wantRules = !string.IsNullOrWhiteSpace(input.RulesVersion);
|
||
var wantPrice = !string.IsNullOrWhiteSpace(input.PriceVersion);
|
||
if (!wantRules && !wantPrice)
|
||
{
|
||
throw new BusinessException("请至少指定规则版本或价格版本");
|
||
}
|
||
|
||
var remarkParts = new List<string>();
|
||
if (wantRules)
|
||
{
|
||
var resolved = await ResolveBindTargetVersionAsync(
|
||
product,
|
||
RulesOperatorType,
|
||
input.RulesVersion!.Trim(),
|
||
"规则");
|
||
if (!string.Equals(order.SalesProductRulesVersion, resolved, StringComparison.Ordinal))
|
||
{
|
||
remarkParts.Add($"规则版本 {order.SalesProductRulesVersion ?? "-"}→{resolved}");
|
||
order.SalesProductRulesVersion = resolved;
|
||
}
|
||
}
|
||
|
||
if (wantPrice)
|
||
{
|
||
var resolved = await ResolveBindTargetVersionAsync(
|
||
product,
|
||
PriceOperatorType,
|
||
input.PriceVersion!.Trim(),
|
||
"价格");
|
||
if (!string.Equals(order.SalesProductPriceVersion, resolved, StringComparison.Ordinal))
|
||
{
|
||
remarkParts.Add($"价格版本 {order.SalesProductPriceVersion ?? "-"}→{resolved}");
|
||
order.SalesProductPriceVersion = resolved;
|
||
}
|
||
}
|
||
|
||
if (remarkParts.Count == 0 && !input.RecalculateReceivable && !input.RecalculatePayable)
|
||
{
|
||
throw new BusinessException("目标版本与当前绑定相同,且未勾选重算");
|
||
}
|
||
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
if (input.RecalculateReceivable)
|
||
{
|
||
EnsureReceivableMutable(order);
|
||
await RecalculateReceivableCoreAsync(order);
|
||
remarkParts.Add("并重算应收");
|
||
}
|
||
|
||
if (input.RecalculatePayable)
|
||
{
|
||
await RecalculatePayableCoreAsync(order);
|
||
remarkParts.Add("并重算应付");
|
||
}
|
||
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.BindSnapshotVersions,
|
||
beforeSnap,
|
||
afterSnap,
|
||
remarkParts.Count == 0 ? null : string.Join(';', remarkParts));
|
||
return (await MapOrderOutputsAsync([order])).First();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析绑定目标版本:CURRENT 取当前有效发布,否则按版本号校验存在。
|
||
/// </summary>
|
||
private async Task<string> ResolveBindTargetVersionAsync(
|
||
SalesProduct product,
|
||
string operatorType,
|
||
string versionOrCurrent,
|
||
string label)
|
||
{
|
||
if (string.Equals(versionOrCurrent, "CURRENT", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var effective = await SalesProductRepository.GetEffectivePublishRecordAsync(
|
||
product.Id,
|
||
operatorType,
|
||
DateTime.Now);
|
||
if (effective == null || string.IsNullOrWhiteSpace(effective.Version))
|
||
{
|
||
throw new BusinessException($"未命中当前有效{label}发布版本");
|
||
}
|
||
|
||
return effective.Version.Trim();
|
||
}
|
||
|
||
var record = await SalesProductRepository.GetPublishRecordByVersionAsync(
|
||
product.Id,
|
||
operatorType,
|
||
versionOrCurrent);
|
||
if (record == null || string.IsNullOrWhiteSpace(record.Version))
|
||
{
|
||
throw new BusinessException($"未找到{label}发布版本:{versionOrCurrent}");
|
||
}
|
||
|
||
return record.Version.Trim();
|
||
}
|
||
|
||
/// <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);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await ReallocateCoreAsync(order, input.Scheme, input.ServiceChannelId);
|
||
order = await GetOrderOrThrowAsync(input.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
var remark = string.IsNullOrWhiteSpace(input.Scheme) ? null : $"scheme={input.Scheme.Trim()}";
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.Reallocate,
|
||
beforeSnap,
|
||
afterSnap,
|
||
remark);
|
||
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();
|
||
var remark = string.IsNullOrWhiteSpace(scheme) ? null : $"scheme={scheme}";
|
||
foreach (var orderId in orderIds)
|
||
{
|
||
try
|
||
{
|
||
var order = await GetOrderOrThrowAsync(orderId);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await ReallocateCoreAsync(order, scheme, channelId);
|
||
order = await GetOrderOrThrowAsync(orderId);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.Reallocate,
|
||
beforeSnap,
|
||
afterSnap,
|
||
remark);
|
||
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);
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
|
||
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);
|
||
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.UpdateAdditionalServices,
|
||
beforeSnap,
|
||
afterSnap);
|
||
|
||
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 (OrderFeeDiscountCostItems.IsDiscount(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 beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
|
||
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);
|
||
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true);
|
||
var typeName = type == OrderFeeTypeCodes.Receivable ? "应收" : "应付";
|
||
var amountText = input.Amount.ToString("0.####", CultureInfo.InvariantCulture);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.AddFee,
|
||
beforeSnap,
|
||
afterSnap,
|
||
$"新增{typeName}「{costItem}」{currency} {amountText}");
|
||
|
||
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,
|
||
allowDesignatedOutsidePool: lockCurrentChannel);
|
||
|
||
// 重新排货成功后写回规则版本,使后续重算应付与当前排货池一致;卖价版本保持契约,不随排货改写。
|
||
if (enforceReallocateGate)
|
||
{
|
||
order.SalesProductRulesVersion = snapshotBinding.RulesVersion;
|
||
}
|
||
|
||
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.OrderStatus is not (
|
||
OrderStatusCodes.Predeclared
|
||
or OrderStatusCodes.PendingAllocation
|
||
or OrderStatusCodes.PendingPayment
|
||
or OrderStatusCodes.PendingShip))
|
||
{
|
||
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,
|
||
ProductId = x.ProductId?.Trim(),
|
||
NonStdProductId = x.NonStdProductId?.Trim(),
|
||
StdProductId = x.StdProductId?.Trim(),
|
||
SkuImage = OmsObjectStorageExtensions.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);
|
||
}
|
||
}
|
||
}
|