All checks were successful
Build and Deploy / deploy (push) Successful in 1m3s
- 新增 GET /order/pay-preview:返回会员折/券互斥后的应付拆解与本单可选券 - POST /order/pay 支持 couponInstanceId(null 沿用已绑定券,0 不用券,>0 指定券),按试算应付扣款并核销券、释放被替换券的锁定 - 被当前订单锁定的券视为可用,修复入库/编辑重算营销金额时误报券被占用 Co-authored-by: Cursor <cursoragent@cursor.com>
3468 lines
132 KiB
C#
3468 lines
132 KiB
C#
using System.Collections.Concurrent;
|
||
using Fake.Application;
|
||
using Fake.UnitOfWork;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Logging;
|
||
using Newtonsoft.Json;
|
||
using Oms.Application.Dtos.Channels;
|
||
using Oms.Application.Dtos.Orders;
|
||
using Oms.Application.Integrations.SmallBagTrack;
|
||
using Oms.Application.Labeling;
|
||
using Oms.Application.Messaging;
|
||
using Oms.Application.Messaging.OrderSideEffects;
|
||
using Oms.Application.Messaging.ParcelCreate;
|
||
using Oms.Application.Shared;
|
||
using Oms.Application.Storage;
|
||
using Oms.Application.Services.Orders.Batch;
|
||
using Oms.Application.Services.Orders.Issues;
|
||
using Oms.Domain.ChannelsAggregate.Consts;
|
||
using Oms.Domain.ChannelsAggregate.Entities;
|
||
using Oms.Domain.ChannelsAggregate.Repositories;
|
||
using Oms.Domain.CustomersAggregate.Entities;
|
||
using Oms.Domain.CustomersAggregate.Enums;
|
||
using Oms.Domain.CustomersAggregate.Repositories;
|
||
using Oms.Domain.MarketingAggregate.Repositories;
|
||
using Oms.Domain.OrdersAggregate.Consts;
|
||
using Oms.Domain.OrdersAggregate.Entities;
|
||
using Oms.Domain.OrdersAggregate.Repositories;
|
||
using Oms.Domain.Shared.Enums;
|
||
using Oms.Domain.SystemAggregate.Entities;
|
||
using Oms.Domain.SystemAggregate.Repositories;
|
||
|
||
namespace Oms.Application.Services.Orders;
|
||
|
||
public partial class OrderService
|
||
{
|
||
private const string PriceOperatorType = "price";
|
||
private const string RulesOperatorType = "rules";
|
||
private const int ParcelBatchMaxCount = 2000;
|
||
private const string InboundBillingProcessingReason = "入库后计费/排货处理中";
|
||
private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费";
|
||
internal const string ForecastBillingProcessingReason = "预报计费/排货处理中";
|
||
|
||
private static readonly ConcurrentDictionary<int, byte> InboundBillingInFlight = new();
|
||
|
||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||
private ICustomerExportQualificationRepository ExportQualificationRepository =>
|
||
LazyServiceProvider.GetRequiredService<ICustomerExportQualificationRepository>();
|
||
|
||
private IMemberLevelRepository MemberLevelRepository => LazyServiceProvider.GetRequiredService<IMemberLevelRepository>();
|
||
|
||
private Task<string?> ResolvePriceTierKeyAsync(int? customerId) =>
|
||
MarketingCustomerHelper.ResolvePriceTierKeyAsync(CustomerRepository, MemberLevelRepository, customerId);
|
||
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
|
||
private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService<IWeightRuleRepository>();
|
||
private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService<IWeightRoundingRepository>();
|
||
private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService<IKpiAreaRepository>();
|
||
private KpiAreaPackService KpiAreaPackService => LazyServiceProvider.GetRequiredService<KpiAreaPackService>();
|
||
private AllocationProviderSnapshotEnricher AllocationProviderSnapshotEnricher =>
|
||
LazyServiceProvider.GetRequiredService<AllocationProviderSnapshotEnricher>();
|
||
|
||
private AutoTriggerRuntimeService AutoTriggerRuntimeService =>
|
||
LazyServiceProvider.GetRequiredService<AutoTriggerRuntimeService>();
|
||
|
||
private OrderAutoTriggerService OrderAutoTriggerService =>
|
||
LazyServiceProvider.GetRequiredService<OrderAutoTriggerService>();
|
||
|
||
private sealed class ParcelCreateContext
|
||
{
|
||
public string? OrderSource { get; init; }
|
||
|
||
public DateTime? PickupTime { get; init; }
|
||
|
||
public int? CustomerSenderAddressId { get; init; }
|
||
|
||
public string? InboundMethod { get; init; }
|
||
}
|
||
|
||
private static ParcelCreateContext? BuildParcelCreateContext(OrderParcelCreateInput input)
|
||
{
|
||
var inbound = string.IsNullOrWhiteSpace(input.InboundMethod)
|
||
? null
|
||
: input.InboundMethod.Trim();
|
||
if (inbound == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var isDoorPickup = string.Equals(
|
||
inbound,
|
||
OrderInboundMethodCodes.DoorPickup,
|
||
StringComparison.OrdinalIgnoreCase);
|
||
return new ParcelCreateContext
|
||
{
|
||
InboundMethod = inbound,
|
||
PickupTime = isDoorPickup ? input.ExpectPickupTime : null,
|
||
CustomerSenderAddressId = isDoorPickup ? input.SenderAddressId : null
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// /order/parcel/create 的实际步骤。调用方已开启事务;这里抛出的异常会回滚订单和已写入的 Outbox。
|
||
/// </summary>
|
||
private async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(
|
||
OrderParcelCreateInput input,
|
||
int? operatorUserId = null,
|
||
ParcelCreateContext? createContext = null)
|
||
{
|
||
// 1. 幂等:同一 RequestId 直接返回已有订单,不再建单、不再发事件。
|
||
if (!string.IsNullOrWhiteSpace(input.RequestId))
|
||
{
|
||
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
|
||
if (existed != null)
|
||
{
|
||
return await BuildParcelCreateOutputAsync(existed);
|
||
}
|
||
}
|
||
|
||
var bindTime = DateTime.Now;
|
||
NormalizeParcelCreateInput(input);
|
||
createContext ??= BuildParcelCreateContext(input);
|
||
|
||
// 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
|
||
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
|
||
input.CustomerId = sessionCustomer.Id;
|
||
|
||
await ValidateParcelCreateInputAsync(input);
|
||
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(input.ProductCode!.Trim());
|
||
if (product == null || product.IsActive != EnableStatus.Enabled)
|
||
{
|
||
throw new BusinessException("销售产品不存在或未启用");
|
||
}
|
||
|
||
EnsureAdditionalServicesAllowed(product, input.AdditionalServices);
|
||
|
||
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
|
||
var country = await GetCountryOrThrowAsync(input.Receiver!.CountryCode);
|
||
|
||
if (await IsBlacklistHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
country.CountryCode,
|
||
input.Receiver?.Province,
|
||
input.Receiver?.City,
|
||
input.Receiver?.ZipCode))
|
||
{
|
||
throw new BusinessException("收件地址命中黑名单区域");
|
||
}
|
||
|
||
await OrderAutoTriggerService.EvaluatePreCommitAsync(
|
||
input,
|
||
sessionCustomer,
|
||
country.CountryCode,
|
||
country.ChineseName);
|
||
|
||
// 3. 落库订单、件重尺和收发件等子表。此后才有 OrderId,才能写 Outbox。
|
||
var serviceNo = await ResolveServiceNoAsync();
|
||
var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||
var order = BuildDraftOrderEntity(
|
||
input,
|
||
product,
|
||
country.CountryCode,
|
||
country.ChineseName,
|
||
snapshotBinding,
|
||
serviceNo,
|
||
createContext);
|
||
order = await OrderRepository.InsertAsync(order);
|
||
|
||
var weights = BuildInitialWeightRows(order.Id, input);
|
||
await SaveParcelChildrenAsync(order, input, weights, country);
|
||
|
||
// 4. 保存草稿:不做预报计费。9610、上门揽收与订单一起提交。
|
||
if (input.SaveAsDraft)
|
||
{
|
||
await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
|
||
{
|
||
OrderId = order.Id,
|
||
PlatformCode = input.PlatformCode
|
||
});
|
||
var draftOutput = await BuildParcelCreateOutputAsync(order);
|
||
await TryAttachPickupAfterParcelCreateAsync(input, draftOutput);
|
||
return draftOutput;
|
||
}
|
||
|
||
// 5. 确认下单:偏远标记当场算完;计费重/应收/排货留给预报任务。
|
||
// 状态先停在草稿 +「预报计费/排货处理中」,接口即可返回。
|
||
order.IsRemote = await IsRemoteHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
country.CountryCode,
|
||
input.Receiver?.Province,
|
||
input.Receiver?.City,
|
||
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
|
||
order.OrderStatus = OrderStatusCodes.Draft;
|
||
order.PredeclareFailReason = ForecastBillingProcessingReason;
|
||
order.AllocationPendingReason = string.Empty;
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
// 6. 与订单同一事务写入 Outbox。Commit 后由 Fake 投递;任一条失败则整单回滚。
|
||
// 轨迹不在此列:要等预报消费端排货拿到 ChannelCode 再发。
|
||
var authorization = CaptureRequestAuthorization();
|
||
await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent
|
||
{
|
||
OrderId = order.Id,
|
||
PlatformCode = input.PlatformCode
|
||
});
|
||
await EnqueueForecastBillingAsync(order, input.CouponInstanceId, operatorId, authorization);
|
||
await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent
|
||
{
|
||
OrderId = order.Id,
|
||
OperatorUserId = operatorId
|
||
});
|
||
await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId);
|
||
|
||
var output = await BuildParcelCreateOutputAsync(order);
|
||
output.ForecastBillingQueued = string.Equals(
|
||
order.PredeclareFailReason,
|
||
ForecastBillingProcessingReason,
|
||
StringComparison.Ordinal);
|
||
// 揽收失败只回写 PickupCreateError,不让整单回滚。
|
||
await TryAttachPickupAfterParcelCreateAsync(input, output);
|
||
return output;
|
||
}
|
||
|
||
private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null)
|
||
{
|
||
try
|
||
{
|
||
await LazyServiceProvider.GetRequiredService<Customs9610OrderWriter>()
|
||
.UpsertFromOrderAsync(order, platformCode);
|
||
}
|
||
catch
|
||
{
|
||
// 9610 同步失败不阻断运单创建
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 已有草稿重新预报失败时,在业务文案末尾附加机器可读标记,供前端区分。
|
||
/// 格式:原因 [[omsDraft:{"draftSaved":true,"orderId":1,"serviceNo":"IDE..."}]]
|
||
/// 同时写入 Exception.Data / ErrorCode,由 OmsException2ResponseConverter 输出到响应 Details。
|
||
/// 注意:parcel/create 失败会回滚订单,不会走此标记;仅 CompleteDraftPredeclare 使用。
|
||
/// </summary>
|
||
private const string DraftSavedMarkerPrefix = " [[omsDraft:";
|
||
private const string DraftSavedMarkerSuffix = "]]";
|
||
|
||
private static string StripDraftSavedMarker(string? message)
|
||
{
|
||
if (string.IsNullOrEmpty(message))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
|
||
return idx < 0 ? message : message[..idx].TrimEnd();
|
||
}
|
||
|
||
private static string WrapDraftSavedFail(Order order, string reason)
|
||
{
|
||
var clean = StripDraftSavedMarker(reason);
|
||
if (string.IsNullOrWhiteSpace(clean))
|
||
{
|
||
clean = "预报未完成";
|
||
}
|
||
|
||
var payload = global::System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object>
|
||
{
|
||
["draftSaved"] = true,
|
||
["orderId"] = order.Id,
|
||
["serviceNo"] = order.ServiceNo ?? string.Empty,
|
||
["reason"] = clean,
|
||
["errorCode"] = OrderParcelDraftFailData.ErrorCodeValue,
|
||
});
|
||
return $"{clean}{DraftSavedMarkerPrefix}{payload}{DraftSavedMarkerSuffix}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 抛出「草稿已落库」业务异常:ErrorCode + Data 结构化载荷,msg 仍带兼容标记。
|
||
/// </summary>
|
||
private static BusinessException CreateDraftSavedBusinessException(Order order, string reason)
|
||
{
|
||
var clean = StripDraftSavedMarker(reason);
|
||
if (string.IsNullOrWhiteSpace(clean))
|
||
{
|
||
clean = "预报未完成";
|
||
}
|
||
|
||
var failData = new OrderParcelDraftFailData
|
||
{
|
||
DraftSaved = true,
|
||
OrderId = order.Id,
|
||
ServiceNo = order.ServiceNo ?? string.Empty,
|
||
Reason = clean,
|
||
ErrorCode = OrderParcelDraftFailData.ErrorCodeValue,
|
||
};
|
||
|
||
var ex = new BusinessException(WrapDraftSavedFail(order, clean));
|
||
ex.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
|
||
ex.Data["draftSaved"] = true;
|
||
ex.Data["orderId"] = order.Id;
|
||
ex.Data["serviceNo"] = order.ServiceNo ?? string.Empty;
|
||
ex.Data[OrderParcelDraftFailData.DataKey] =
|
||
global::System.Text.Json.JsonSerializer.Serialize(failData);
|
||
return ex;
|
||
}
|
||
|
||
private const string ParcelServiceNoPrefix = "IDE";
|
||
|
||
/// <summary>
|
||
/// 自动生成运单号:IDE + yyMMddHHmmss + 4 位随机数(如 IDE2607221859123456)。
|
||
/// </summary>
|
||
private static string BuildParcelServiceNo(DateTime now, int randomSuffix) =>
|
||
$"{ParcelServiceNoPrefix}{now:yyMMddHHmmss}{randomSuffix}";
|
||
|
||
private static void NormalizeParcelCreateInput(OrderParcelCreateInput input)
|
||
{
|
||
input.TrackNo = null;
|
||
input.ServiceNo = null;
|
||
input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim();
|
||
input.RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim();
|
||
if (!string.IsNullOrWhiteSpace(input.CargoAttribute))
|
||
{
|
||
input.CargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoAttribute.Trim());
|
||
}
|
||
}
|
||
|
||
private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(input.ClientNo))
|
||
{
|
||
throw new BusinessException("客户单号不能为空");
|
||
}
|
||
|
||
if (!input.CustomerId.HasValue || input.CustomerId <= 0)
|
||
{
|
||
throw new BusinessException("客户ID不能为空");
|
||
}
|
||
|
||
var duplicatedClientNo = await OrderRepository.GetByCustomerAndClientNoAsync(
|
||
input.CustomerId.Value,
|
||
input.ClientNo);
|
||
if (duplicatedClientNo != null)
|
||
{
|
||
throw new BusinessException("客户单号已存在");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(input.ProductCode))
|
||
{
|
||
throw new BusinessException("产品代码不能为空");
|
||
}
|
||
|
||
if (!input.WarehouseId.HasValue || input.WarehouseId <= 0)
|
||
{
|
||
throw new BusinessException("仓库ID不能为空");
|
||
}
|
||
|
||
if (!ParcelBillingValidators.HasFullDimensions(input.Length, input.Width, input.Height)
|
||
|| !input.ActualWeight.HasValue || input.ActualWeight <= 0)
|
||
{
|
||
throw new BusinessException("件重尺必须大于0");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(input.CargoAttribute)
|
||
|| input.CargoAttribute is not (CargoAttributeCodes.General or CargoAttributeCodes.Electronics or CargoAttributeCodes.Sensitive))
|
||
{
|
||
throw new BusinessException("货物属性不合法");
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(input.ExportCustomsMode)
|
||
&& !ExportCustomsModeCodes.All.Contains(input.ExportCustomsMode))
|
||
{
|
||
throw new BusinessException("出口报关方式不合法");
|
||
}
|
||
|
||
var is9610Summary = string.Equals(
|
||
input.ExportCustomsMode,
|
||
ExportCustomsModeCodes.Mode9610Summary,
|
||
StringComparison.OrdinalIgnoreCase);
|
||
if (is9610Summary)
|
||
{
|
||
CustomerExportQualification? qualification = null;
|
||
if (input.ExportQualificationId is > 0)
|
||
{
|
||
qualification = await ExportQualificationRepository.GetOwnedAsync(
|
||
input.ExportQualificationId.Value,
|
||
input.CustomerId!.Value);
|
||
if (qualification == null)
|
||
{
|
||
throw new BusinessException("出口资质备案不存在或不属于当前客户");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
qualification = await ExportQualificationRepository.GetDefaultAsync(input.CustomerId!.Value);
|
||
if (qualification == null)
|
||
{
|
||
throw new BusinessException("9610汇总申报必须选择出口资质备案(或先维护默认备案)");
|
||
}
|
||
|
||
input.ExportQualificationId = qualification.Id;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(input.PlatformCode))
|
||
{
|
||
throw new BusinessException("9610汇总申报必须填写电商平台");
|
||
}
|
||
|
||
input.PlatformCode = input.PlatformCode.Trim();
|
||
if (input.PlatformCode.Length > 16)
|
||
{
|
||
throw new BusinessException("电商平台长度不能超过 16");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
input.ExportQualificationId = null;
|
||
input.PlatformCode = null;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(input.DeclaredCurrency))
|
||
{
|
||
throw new BusinessException("申报币种不能为空");
|
||
}
|
||
|
||
if (!input.Quantity.HasValue || input.Quantity <= 0)
|
||
{
|
||
throw new BusinessException("包裹数量不能为空");
|
||
}
|
||
|
||
if (input.Receiver == null
|
||
|| string.IsNullOrWhiteSpace(input.Receiver.RecipientsName)
|
||
|| string.IsNullOrWhiteSpace(input.Receiver.CountryCode)
|
||
|| string.IsNullOrWhiteSpace(input.Receiver.ZipCode)
|
||
|| string.IsNullOrWhiteSpace(input.Receiver.StreetAddress1))
|
||
{
|
||
throw new BusinessException("收件人信息不完整");
|
||
}
|
||
|
||
if (input.Sender == null || string.IsNullOrWhiteSpace(input.Sender.SenderName))
|
||
{
|
||
throw new BusinessException("发件人姓名不能为空");
|
||
}
|
||
|
||
if (input.Items.Count == 0)
|
||
{
|
||
throw new BusinessException("申报明细不能为空");
|
||
}
|
||
|
||
foreach (var item in input.Items)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(item.EnName)
|
||
|| !item.UnitPrice.HasValue
|
||
|| !item.UnitWeight.HasValue
|
||
|| !item.Quantity.HasValue
|
||
|| item.Quantity <= 0)
|
||
{
|
||
throw new BusinessException("申报明细必填字段不完整");
|
||
}
|
||
}
|
||
|
||
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == input.WarehouseId.Value);
|
||
if (warehouse == null)
|
||
{
|
||
throw new BusinessException("仓库不存在");
|
||
}
|
||
|
||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId.Value);
|
||
if (customer == null || customer.Status != CustomerStatus.Normal)
|
||
{
|
||
throw new BusinessException("客户不存在或状态不可用");
|
||
}
|
||
|
||
CustomerContactHelper.EnsureContactComplete(customer);
|
||
}
|
||
|
||
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingAsync(SalesProduct product, DateTime bindTime)
|
||
{
|
||
var rulesRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, RulesOperatorType, bindTime);
|
||
var priceRecord = await SalesProductRepository.GetEffectivePublishRecordAsync(product.Id, PriceOperatorType, bindTime);
|
||
|
||
SalesProductRulesPublishSnapshotDto rulesSnapshot;
|
||
if (rulesRecord == null)
|
||
{
|
||
rulesSnapshot = await BuildLiveRulesSnapshotAsync(product);
|
||
}
|
||
else
|
||
{
|
||
rulesSnapshot = OmsDocHelper.DeserializeRulesSnapshot(rulesRecord.Content)
|
||
?? throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
|
||
}
|
||
|
||
SalesProductPricePublishSnapshotDto priceSnapshot;
|
||
if (priceRecord == null)
|
||
{
|
||
priceSnapshot = await BuildLivePriceSnapshotAsync(product);
|
||
if (priceSnapshot.Prices.Count == 0)
|
||
{
|
||
throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
priceSnapshot = OmsDocHelper.DeserializePriceSnapshot(priceRecord.Content)
|
||
?? throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
|
||
if (priceSnapshot.Prices.Count == 0)
|
||
{
|
||
throw new BusinessException("未命中价格快照版本(内存/Redis/DB)");
|
||
}
|
||
}
|
||
|
||
if (rulesRecord == null && !product.WeightRuleId.HasValue)
|
||
{
|
||
throw new BusinessException("未命中规则快照版本(内存/Redis/DB)");
|
||
}
|
||
|
||
var binding = new ProductSnapshotBinding
|
||
{
|
||
RulesVersion = rulesRecord?.Version ?? $"LIVE-{product.Id}",
|
||
PriceVersion = priceRecord?.Version ?? $"LIVE-{product.Id}",
|
||
RulesEffectiveTime = rulesRecord?.PublishTime ?? bindTime,
|
||
PriceEffectiveTime = priceRecord?.PublishTime ?? bindTime,
|
||
Rules = rulesSnapshot,
|
||
Price = priceSnapshot
|
||
};
|
||
await KpiAreaPackService.HydrateBindingAsync(binding);
|
||
return binding;
|
||
}
|
||
|
||
private async Task<ProductSnapshotBinding> ResolveSnapshotBindingFromOrderAsync(SalesProduct product, Order order)
|
||
{
|
||
var bindTime = order.CreateTime == default ? DateTime.Now : order.CreateTime;
|
||
SalesProductRulesPublishSnapshotDto rulesSnapshot;
|
||
string rulesVersion;
|
||
DateTime rulesEffectiveTime;
|
||
|
||
if (string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
|
||
|| order.SalesProductRulesVersion.StartsWith("LIVE-", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
rulesSnapshot = await BuildLiveRulesSnapshotAsync(product);
|
||
rulesVersion = order.SalesProductRulesVersion ?? $"LIVE-{product.Id}";
|
||
rulesEffectiveTime = bindTime;
|
||
}
|
||
else
|
||
{
|
||
var rulesRecord = await SalesProductRepository.GetPublishRecordByVersionAsync(
|
||
product.Id,
|
||
RulesOperatorType,
|
||
order.SalesProductRulesVersion);
|
||
if (rulesRecord?.Content == null)
|
||
{
|
||
throw new BusinessException("未命中订单绑定的规则快照版本");
|
||
}
|
||
|
||
rulesSnapshot = OmsDocHelper.DeserializeRulesSnapshot(rulesRecord.Content)
|
||
?? throw new BusinessException("未命中订单绑定的规则快照版本");
|
||
rulesVersion = rulesRecord.Version;
|
||
rulesEffectiveTime = rulesRecord.PublishTime;
|
||
}
|
||
|
||
SalesProductPricePublishSnapshotDto priceSnapshot;
|
||
string priceVersion;
|
||
DateTime priceEffectiveTime;
|
||
|
||
if (string.IsNullOrWhiteSpace(order.SalesProductPriceVersion)
|
||
|| order.SalesProductPriceVersion.StartsWith("LIVE-", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
priceSnapshot = await BuildLivePriceSnapshotAsync(product);
|
||
if (priceSnapshot.Prices.Count == 0)
|
||
{
|
||
throw new BusinessException("未命中订单绑定的价格快照版本");
|
||
}
|
||
|
||
priceVersion = order.SalesProductPriceVersion ?? $"LIVE-{product.Id}";
|
||
priceEffectiveTime = bindTime;
|
||
}
|
||
else
|
||
{
|
||
var priceRecord = await SalesProductRepository.GetPublishRecordByVersionAsync(
|
||
product.Id,
|
||
PriceOperatorType,
|
||
order.SalesProductPriceVersion);
|
||
if (priceRecord?.Content == null)
|
||
{
|
||
throw new BusinessException("未命中订单绑定的价格快照版本");
|
||
}
|
||
|
||
priceSnapshot = OmsDocHelper.DeserializePriceSnapshot(priceRecord.Content)
|
||
?? throw new BusinessException("未命中订单绑定的价格快照版本");
|
||
if (priceSnapshot.Prices.Count == 0)
|
||
{
|
||
throw new BusinessException("未命中订单绑定的价格快照版本");
|
||
}
|
||
|
||
priceVersion = priceRecord.Version;
|
||
priceEffectiveTime = priceRecord.PublishTime;
|
||
}
|
||
|
||
var binding = new ProductSnapshotBinding
|
||
{
|
||
RulesVersion = rulesVersion,
|
||
PriceVersion = priceVersion,
|
||
RulesEffectiveTime = rulesEffectiveTime,
|
||
PriceEffectiveTime = priceEffectiveTime,
|
||
Rules = rulesSnapshot,
|
||
Price = priceSnapshot
|
||
};
|
||
await KpiAreaPackService.HydrateBindingAsync(binding);
|
||
return binding;
|
||
}
|
||
|
||
private async Task<string?> ResolveWarehouseNameAsync(int? warehouseId)
|
||
{
|
||
if (!warehouseId.HasValue || warehouseId.Value <= 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
|
||
return warehouse?.WarehouseName;
|
||
}
|
||
|
||
private async Task<OrderBillingAddress> ResolveOrderBillingAddressAsync(Order order)
|
||
{
|
||
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
|
||
var recipient = recipients.FirstOrDefault();
|
||
return new OrderBillingAddress(
|
||
order.ConsigneeCountry ?? string.Empty,
|
||
recipient?.Province,
|
||
recipient?.City,
|
||
recipient?.ZipCode);
|
||
}
|
||
|
||
private async Task<IReadOnlyList<string>> ResolveZoneKeysOrThrowAsync(
|
||
SalesProduct product,
|
||
ProductSnapshotBinding snapshotBinding,
|
||
OrderBillingAddress address)
|
||
{
|
||
var zoneKeys = await ResolvePriceZoneKeysAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
snapshotBinding.Price,
|
||
address.CountryCode,
|
||
address.Province,
|
||
address.City,
|
||
address.ZipCode);
|
||
if (zoneKeys == null || zoneKeys.Count == 0)
|
||
{
|
||
throw new BusinessException("价格分区未匹配(规则快照)");
|
||
}
|
||
|
||
return zoneKeys;
|
||
}
|
||
|
||
private async Task<OrderBillingContext> ResolveBillingContextAsync(
|
||
SalesProduct product,
|
||
ProductSnapshotBinding snapshotBinding,
|
||
OrderBillingAddress address,
|
||
decimal chargeableWeight,
|
||
string cargoAttribute,
|
||
int? warehouseId,
|
||
string? priceTierKey = null)
|
||
{
|
||
if (chargeableWeight <= 0)
|
||
{
|
||
throw new BusinessException("计费重无效,无法计费");
|
||
}
|
||
|
||
var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, snapshotBinding, address);
|
||
var warehouseName = await ResolveWarehouseNameAsync(warehouseId);
|
||
return new OrderBillingContext
|
||
{
|
||
SnapshotBinding = snapshotBinding,
|
||
ZoneKeys = zoneKeys,
|
||
ChargeableWeight = chargeableWeight,
|
||
CargoAttribute = cargoAttribute,
|
||
PriceTierKey = priceTierKey,
|
||
WarehouseId = warehouseId,
|
||
WarehouseName = warehouseName
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析已有订单的算价上下文(绑定订单快照版本)。
|
||
/// </summary>
|
||
private async Task<OrderBillingContext> ResolveOrderBillingContextAsync(
|
||
Order order,
|
||
SalesProduct product,
|
||
decimal chargeableWeight,
|
||
ProductSnapshotBinding? snapshotBinding = null)
|
||
{
|
||
if (chargeableWeight <= 0)
|
||
{
|
||
throw new BusinessException("计费重无效,无法计费");
|
||
}
|
||
|
||
var binding = snapshotBinding ?? await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||
var address = await ResolveOrderBillingAddressAsync(order);
|
||
var zoneKeys = await ResolveZoneKeysOrThrowAsync(product, binding, address);
|
||
var priceTierKey = await ResolvePriceTierKeyAsync(order.CustomerId);
|
||
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
|
||
return new OrderBillingContext
|
||
{
|
||
SnapshotBinding = binding,
|
||
ZoneKeys = zoneKeys,
|
||
ChargeableWeight = chargeableWeight,
|
||
CargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General,
|
||
PriceTierKey = priceTierKey,
|
||
WarehouseId = order.WarehouseId,
|
||
WarehouseName = warehouseName
|
||
};
|
||
}
|
||
|
||
private async Task UpsertReceivableFeesAsync(
|
||
Order order,
|
||
OrderBillingContext billing,
|
||
string chargingStage,
|
||
bool replaceExistingReceivable = false)
|
||
{
|
||
await UpsertReceivableFeesAsync(
|
||
order,
|
||
billing.SnapshotBinding,
|
||
billing.ChargeableWeight,
|
||
billing.ZoneKeys,
|
||
billing.CargoAttribute,
|
||
billing.PriceTierKey,
|
||
chargingStage,
|
||
replaceExistingReceivable,
|
||
billing.WarehouseId,
|
||
billing.WarehouseName);
|
||
}
|
||
|
||
private async Task UpsertReceivableFeesAsync(
|
||
Order order,
|
||
ProductSnapshotBinding snapshotBinding,
|
||
decimal chargeableWeight,
|
||
IReadOnlyList<string> zoneKeys,
|
||
string cargoAttribute,
|
||
string? priceTierKey,
|
||
string chargingStage,
|
||
bool replaceExistingReceivable = false,
|
||
int? warehouseId = null,
|
||
string? warehouseName = null)
|
||
{
|
||
var conditionContext = BuildBillingConditionContext(order);
|
||
var remoteAreaId = order.IsRemote > 0
|
||
? (snapshotBinding.Rules?.RemoteAreaId
|
||
?? snapshotBinding.Rules?.Product?.RemoteAreaId)
|
||
: null;
|
||
var remoteZoneKeys = ParcelBillingCalculator.BuildRemoteZoneKeys(
|
||
remoteAreaId is > 0 ? remoteAreaId : null,
|
||
snapshotBinding.Rules?.RemoteAreaName);
|
||
var fees = ParcelBillingCalculator.CalculateReceivableFees(
|
||
snapshotBinding.Price,
|
||
snapshotBinding.PriceVersion,
|
||
snapshotBinding.PriceEffectiveTime,
|
||
chargeableWeight,
|
||
zoneKeys,
|
||
cargoAttribute,
|
||
priceTierKey,
|
||
chargingStage,
|
||
warehouseId ?? order.WarehouseId,
|
||
warehouseName,
|
||
conditionContext,
|
||
order.DeclaredSum,
|
||
order.DeclaredCurrency,
|
||
remoteZoneKeys);
|
||
|
||
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledReceivablePriceItems(snapshotBinding.Price))
|
||
{
|
||
if (chargeableWeight <= 0)
|
||
{
|
||
throw new BusinessException("计费重无效,无法计算应收费用");
|
||
}
|
||
|
||
var detail = ParcelBillingCalculator.ExplainQuotePriceMiss(
|
||
snapshotBinding.Price,
|
||
chargeableWeight,
|
||
zoneKeys,
|
||
cargoAttribute,
|
||
priceTierKey,
|
||
warehouseId ?? order.WarehouseId,
|
||
warehouseName);
|
||
throw new BusinessException($"产品卖价未匹配到有效价格:{detail}");
|
||
}
|
||
|
||
if (fees.Count > 0)
|
||
{
|
||
if (replaceExistingReceivable)
|
||
{
|
||
// 有效应收只保留一套:清空全部阶段后再写入当前阶段,避免预报+入库叠算。
|
||
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
|
||
}
|
||
|
||
await OrderRepository.UpsertFeesAsync(order.Id, fees);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按排货规则确认服务渠道,并写入应付费用(渠道买价)。
|
||
/// 仅用于入库、重算应付等内部环节;创建订单时不调用。
|
||
/// </summary>
|
||
private async Task ConfirmServiceChannelAndPayableAsync(
|
||
Order order,
|
||
SalesProduct product,
|
||
ProductSnapshotBinding snapshotBinding,
|
||
IReadOnlyList<string> zoneKeys,
|
||
decimal chargeableWeight,
|
||
string chargingStage,
|
||
string? _,
|
||
string? schemeOverride = null,
|
||
int? forceServiceChannelId = null,
|
||
bool allowDesignatedOutsidePool = false)
|
||
{
|
||
var evaluation = await EvaluateAllocationAsync(
|
||
order,
|
||
product,
|
||
snapshotBinding,
|
||
zoneKeys,
|
||
chargeableWeight,
|
||
chargingStage,
|
||
schemeOverride,
|
||
forceServiceChannelId,
|
||
allowDesignatedOutsidePool);
|
||
|
||
ApplyAllocationTraceToOrder(order, evaluation);
|
||
|
||
if (!string.IsNullOrWhiteSpace(evaluation.FailReason) || evaluation.Selected == null)
|
||
{
|
||
throw new BusinessException(evaluation.FailReason ?? "排货规则未匹配到可用服务渠道");
|
||
}
|
||
|
||
var selectedCandidate = evaluation.Selected;
|
||
var payableFees = selectedCandidate.PayableFees.ToList();
|
||
if (payableFees.Count == 0)
|
||
{
|
||
evaluation.FailReason = "渠道买价未配置或未匹配到有效价格";
|
||
ApplyAllocationTraceToOrder(order, evaluation);
|
||
throw new BusinessException(evaluation.FailReason);
|
||
}
|
||
|
||
var previousChannelCode = order.ChannelCode?.Trim();
|
||
var newChannelCode = selectedCandidate.Channel.ChannelCode?.Trim() ?? string.Empty;
|
||
var channelChanged = !string.Equals(
|
||
previousChannelCode,
|
||
newChannelCode,
|
||
StringComparison.OrdinalIgnoreCase);
|
||
|
||
order.ChannelCode = selectedCandidate.Channel.ChannelCode;
|
||
AllocationProviderSnapshotEnricher.ApplyDownstreamFieldsToOrder(
|
||
order,
|
||
selectedCandidate.Provider,
|
||
selectedCandidate.Channel);
|
||
|
||
// 换渠道后旧下游单号/跟踪号失效,清空以免「新渠道+旧服务商单号」。
|
||
if (channelChanged
|
||
&& (!string.IsNullOrWhiteSpace(order.DownstreamServiceNo)
|
||
|| !string.IsNullOrWhiteSpace(order.TrackNo)))
|
||
{
|
||
order.DownstreamServiceNo = null;
|
||
order.TrackNo = null;
|
||
}
|
||
|
||
// 有效应付只保留本阶段一套,避免 forecast+inbound 叠行虚增成本。
|
||
await OrderRepository.DeletePayableFeesAsync(order.Id);
|
||
await OrderRepository.UpsertFeesAsync(order.Id, payableFees);
|
||
order.AllocationPendingReason = string.Empty;
|
||
}
|
||
|
||
private sealed class AllocationEvaluation
|
||
{
|
||
public string StoredScheme { get; init; } = AllocationSchemeCodes.CostLowest;
|
||
public string SelectScheme { get; init; } = AllocationSchemeCodes.CostLowest;
|
||
public string SchemeSource { get; init; } = "productDefault";
|
||
public decimal ChargeableWeight { get; init; }
|
||
public string ChargingStage { get; init; } = string.Empty;
|
||
public IReadOnlyList<string> ZoneKeys { get; init; } = [];
|
||
public List<AllocationResolver.ChannelCandidate> Candidates { get; init; } = [];
|
||
public List<OrderAllocationSkippedOutput> Skipped { get; init; } = [];
|
||
public int PoolCount { get; init; }
|
||
public int ConditionPassCount { get; init; }
|
||
public AllocationResolver.ChannelCandidate? Selected { get; set; }
|
||
public string? FailReason { get; set; }
|
||
}
|
||
|
||
private async Task<AllocationEvaluation> EvaluateAllocationAsync(
|
||
Order order,
|
||
SalesProduct product,
|
||
ProductSnapshotBinding snapshotBinding,
|
||
IReadOnlyList<string> zoneKeys,
|
||
decimal chargeableWeight,
|
||
string chargingStage,
|
||
string? schemeOverride,
|
||
int? forceServiceChannelId,
|
||
bool allowDesignatedOutsidePool = false)
|
||
{
|
||
var warehouseName = await ResolveWarehouseNameAsync(order.WarehouseId);
|
||
var providers = await ResolveAllocationProvidersAsync(product, snapshotBinding.Rules);
|
||
// 判断条件需要客户名、件重尺等,与自动触发共用完整上下文
|
||
var ruleContext = await OrderAutoTriggerService.BuildPostCommitContextAsync(order);
|
||
var filterResult = AllocationResolver.FilterProvidersDetailed(
|
||
providers,
|
||
order.CustomerId,
|
||
ruleContext);
|
||
var skipped = filterResult.Skipped.ToList();
|
||
var filtered = filterResult.Passed;
|
||
var poolCount = filterResult.PoolCount;
|
||
var conditionPassCount = filterResult.ConditionPassCount;
|
||
|
||
var productScheme = snapshotBinding.Rules.Product?.Scheme
|
||
?? product.Scheme
|
||
?? AllocationSchemeCodes.CostLowest;
|
||
var hasOverride = !string.IsNullOrWhiteSpace(schemeOverride);
|
||
var isDesignated = forceServiceChannelId is > 0
|
||
|| AllocationSchemeCodes.IsDesignated(schemeOverride);
|
||
var storedScheme = isDesignated
|
||
? AllocationSchemeCodes.Designated
|
||
: hasOverride
|
||
? schemeOverride!.Trim()
|
||
: productScheme;
|
||
var schemeSource = isDesignated
|
||
? "designated"
|
||
: hasOverride
|
||
? "reallocate"
|
||
: "productDefault";
|
||
var selectScheme = AllocationSchemeCodes.IsDesignated(storedScheme)
|
||
? AllocationSchemeCodes.CostLowest
|
||
: storedScheme;
|
||
|
||
AllocationEvaluation BaseEval(string? failReason = null) => new()
|
||
{
|
||
StoredScheme = storedScheme,
|
||
SelectScheme = selectScheme,
|
||
SchemeSource = schemeSource,
|
||
ChargeableWeight = chargeableWeight,
|
||
ChargingStage = chargingStage,
|
||
ZoneKeys = zoneKeys,
|
||
Skipped = skipped,
|
||
PoolCount = poolCount,
|
||
ConditionPassCount = conditionPassCount,
|
||
FailReason = failReason
|
||
};
|
||
|
||
if (forceServiceChannelId is > 0)
|
||
{
|
||
var designated = filtered
|
||
.Where(x => x.ServiceChannelId == forceServiceChannelId.Value)
|
||
.ToList();
|
||
if (designated.Count == 0)
|
||
{
|
||
var poolRow = providers.FirstOrDefault(x => x.ServiceChannelId == forceServiceChannelId.Value);
|
||
// 重算应付锁定已确认渠道:渠道已在单上,不再用旧规则排货池/判断条件拦截,只重算买价。
|
||
if (allowDesignatedOutsidePool)
|
||
{
|
||
var lockedChannel = await ServiceChannelRepository.FirstOrDefaultAsync(x =>
|
||
x.Id == forceServiceChannelId.Value);
|
||
if (lockedChannel == null)
|
||
{
|
||
return BaseEval("订单服务渠道不存在,无法重算应付");
|
||
}
|
||
|
||
var lockedProvider = poolRow ?? new SalesProductAllocationProviderSnapshotDto
|
||
{
|
||
SalesProductId = product.Id,
|
||
ServiceChannelId = lockedChannel.Id,
|
||
ServiceChannelName = lockedChannel.ChannelName,
|
||
ProviderName = lockedChannel.ChannelName ?? lockedChannel.ChannelCode,
|
||
ChannelCode = lockedChannel.ChannelCode,
|
||
Weight = 0,
|
||
CustomerIds = [],
|
||
MatchCondition = null
|
||
};
|
||
if (poolRow == null
|
||
|| AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment(lockedProvider))
|
||
{
|
||
await AllocationProviderSnapshotEnricher.EnrichAsync([lockedProvider]);
|
||
}
|
||
|
||
filtered = [lockedProvider];
|
||
}
|
||
else
|
||
{
|
||
var failReason = poolRow == null
|
||
? "指定渠道不在该销售产品排货池"
|
||
: "指定渠道不满足判断条件";
|
||
if (poolRow != null
|
||
&& skipped.All(x => x.ServiceChannelId != forceServiceChannelId.Value))
|
||
{
|
||
skipped.Add(new OrderAllocationSkippedOutput
|
||
{
|
||
ServiceChannelId = forceServiceChannelId.Value,
|
||
ChannelCode = poolRow.ChannelCode,
|
||
ChannelName = poolRow.ServiceChannelName ?? poolRow.ProviderName,
|
||
Stage = AllocationResolver.SkipStageCondition,
|
||
MatchCondition = AllocationResolver.TruncateMatchCondition(poolRow.MatchCondition),
|
||
Reason = failReason
|
||
});
|
||
}
|
||
|
||
return BaseEval(failReason);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
filtered = designated;
|
||
}
|
||
}
|
||
|
||
if (filtered.Count == 0)
|
||
{
|
||
var conditionMiss = skipped.Count(x =>
|
||
x.Stage is AllocationResolver.SkipStageCondition or AllocationResolver.SkipStageCustomer);
|
||
var failReason = poolCount == 0
|
||
? "排货规则未配置可用服务渠道"
|
||
: conditionMiss > 0
|
||
? $"排货规则未匹配到可用服务渠道(条件未命中 {conditionMiss}/{poolCount})"
|
||
: "排货规则未匹配到可用服务渠道";
|
||
return BaseEval(failReason);
|
||
}
|
||
|
||
// 应付买价取「当前」有效发布(PublishTime<=Now),不用下单时间,避免重新排货/入库漏掉后续发布的买价。
|
||
var bindTime = DateTime.Now;
|
||
var candidates = new List<AllocationResolver.ChannelCandidate>();
|
||
var recipient = ruleContext.Recipient
|
||
?? (await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id])).FirstOrDefault();
|
||
var cargoAttribute = order.CargoAttribute ?? CargoAttributeCodes.General;
|
||
|
||
foreach (var provider in filtered)
|
||
{
|
||
var channelId = provider.ServiceChannelId!.Value;
|
||
var channel = await ServiceChannelRepository.FirstOrDefaultAsync(x => x.Id == channelId);
|
||
if (channel == null)
|
||
{
|
||
skipped.Add(new OrderAllocationSkippedOutput
|
||
{
|
||
ServiceChannelId = channelId,
|
||
Stage = AllocationResolver.SkipStageChannel,
|
||
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
|
||
Reason = "服务渠道不存在"
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (channel.IsActive != EnableStatus.Enabled)
|
||
{
|
||
skipped.Add(new OrderAllocationSkippedOutput
|
||
{
|
||
ServiceChannelId = channel.Id,
|
||
ChannelCode = channel.ChannelCode,
|
||
ChannelName = channel.ChannelName,
|
||
Stage = AllocationResolver.SkipStageChannel,
|
||
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
|
||
Reason = "服务渠道未启用"
|
||
});
|
||
continue;
|
||
}
|
||
|
||
var channelRulesBinding = await ResolveChannelRulesBindingAsync(channel, bindTime);
|
||
var channelIsRemote = IsChannelRemoteHit(
|
||
channelRulesBinding,
|
||
order.ConsigneeCountry ?? string.Empty,
|
||
recipient?.Province,
|
||
recipient?.City,
|
||
recipient?.ZipCode);
|
||
var payableContext = BuildBillingConditionContext(
|
||
order,
|
||
channelIsRemote ? (byte)1 : (byte)0);
|
||
|
||
var channelPriceBinding = await ResolveChannelPriceBindingAsync(channel.Id, bindTime);
|
||
var channelZoneKeys = ResolveChannelZoneKeysOrSkip(
|
||
channelPriceBinding,
|
||
order.ConsigneeCountry ?? string.Empty,
|
||
recipient?.Province,
|
||
recipient?.City,
|
||
recipient?.ZipCode,
|
||
out var channelZoneMissReason);
|
||
if (channelZoneKeys == null)
|
||
{
|
||
skipped.Add(new OrderAllocationSkippedOutput
|
||
{
|
||
ServiceChannelId = channel.Id,
|
||
ChannelCode = channel.ChannelCode,
|
||
ChannelName = channel.ChannelName,
|
||
Stage = AllocationResolver.SkipStageBuyPrice,
|
||
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
|
||
Reason = channelZoneMissReason
|
||
?? $"渠道买价分区未匹配(版本 {channelPriceBinding.PriceVersion})"
|
||
});
|
||
continue;
|
||
}
|
||
|
||
var channelRemoteZoneKeys = channelIsRemote
|
||
? ParcelBillingCalculator.BuildRemoteZoneKeys(
|
||
channelRulesBinding.RemoteAreaId ?? channel.RemoteAreaId)
|
||
: Array.Empty<string>();
|
||
var previewFees = ParcelBillingCalculator.CalculatePayableFees(
|
||
channelPriceBinding.Prices,
|
||
channelPriceBinding.PriceVersion,
|
||
channelPriceBinding.PriceEffectiveTime,
|
||
chargeableWeight,
|
||
channelZoneKeys,
|
||
cargoAttribute,
|
||
chargingStage,
|
||
order.WarehouseId,
|
||
warehouseName,
|
||
payableContext,
|
||
order.DeclaredSum,
|
||
order.DeclaredCurrency,
|
||
channelRemoteZoneKeys);
|
||
if (previewFees.Count == 0)
|
||
{
|
||
var miss = ParcelBillingCalculator.ExplainPayablePriceMiss(
|
||
channelPriceBinding.Prices,
|
||
chargeableWeight,
|
||
channelZoneKeys,
|
||
cargoAttribute,
|
||
order.WarehouseId,
|
||
warehouseName,
|
||
channelRemoteZoneKeys);
|
||
skipped.Add(new OrderAllocationSkippedOutput
|
||
{
|
||
ServiceChannelId = channel.Id,
|
||
ChannelCode = channel.ChannelCode,
|
||
ChannelName = channel.ChannelName,
|
||
Stage = AllocationResolver.SkipStageBuyPrice,
|
||
MatchCondition = AllocationResolver.TruncateMatchCondition(provider.MatchCondition),
|
||
Reason = string.IsNullOrWhiteSpace(miss)
|
||
? $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion})"
|
||
: $"未匹配到有效渠道买价(版本 {channelPriceBinding.PriceVersion}):{miss}"
|
||
});
|
||
continue;
|
||
}
|
||
|
||
candidates.Add(new AllocationResolver.ChannelCandidate
|
||
{
|
||
Provider = provider,
|
||
Channel = channel,
|
||
TotalPayableCost = previewFees.Sum(x => x.Amount),
|
||
PayableFees = previewFees
|
||
});
|
||
}
|
||
|
||
if (candidates.Count == 0)
|
||
{
|
||
var buyPriceMiss = skipped.Count(x => x.Stage == AllocationResolver.SkipStageBuyPrice);
|
||
var detail = skipped.FirstOrDefault(x =>
|
||
x.Stage == AllocationResolver.SkipStageBuyPrice
|
||
&& !string.IsNullOrWhiteSpace(x.Reason))?.Reason;
|
||
var failReason = chargeableWeight <= 0
|
||
? "计费重无效,无法计算应付费用"
|
||
: buyPriceMiss > 0
|
||
? (string.IsNullOrWhiteSpace(detail)
|
||
? $"排货规则未匹配到有效渠道买价(买价未命中 {buyPriceMiss})"
|
||
: $"排货规则未匹配到有效渠道买价;{detail}")
|
||
: "排货规则未匹配到有效渠道买价";
|
||
return new AllocationEvaluation
|
||
{
|
||
StoredScheme = storedScheme,
|
||
SelectScheme = selectScheme,
|
||
SchemeSource = schemeSource,
|
||
ChargeableWeight = chargeableWeight,
|
||
ChargingStage = chargingStage,
|
||
ZoneKeys = zoneKeys,
|
||
Candidates = candidates,
|
||
Skipped = skipped,
|
||
PoolCount = poolCount,
|
||
ConditionPassCount = conditionPassCount,
|
||
FailReason = failReason
|
||
};
|
||
}
|
||
|
||
var selected = AllocationResolver.SelectCandidate(selectScheme, candidates);
|
||
return new AllocationEvaluation
|
||
{
|
||
StoredScheme = storedScheme,
|
||
SelectScheme = selectScheme,
|
||
SchemeSource = schemeSource,
|
||
ChargeableWeight = chargeableWeight,
|
||
ChargingStage = chargingStage,
|
||
ZoneKeys = zoneKeys,
|
||
Candidates = candidates,
|
||
Skipped = skipped,
|
||
PoolCount = poolCount,
|
||
ConditionPassCount = conditionPassCount,
|
||
Selected = selected
|
||
};
|
||
}
|
||
|
||
private static void ApplyAllocationTraceToOrder(Order order, AllocationEvaluation evaluation)
|
||
{
|
||
var selectedCode = evaluation.Selected?.Channel.ChannelCode;
|
||
var output = BuildAllocationTraceOutput(
|
||
order.Id,
|
||
evaluation,
|
||
fromSnapshot: true,
|
||
selectedChannelCode: selectedCode);
|
||
order.AllocationScheme = evaluation.StoredScheme;
|
||
order.AllocationTraceJson = JsonConvert.SerializeObject(output);
|
||
order.AllocationTraceTime = DateTime.Now;
|
||
}
|
||
|
||
private static OrderAllocationTraceOutput BuildAllocationTraceOutput(
|
||
int orderId,
|
||
AllocationEvaluation evaluation,
|
||
bool fromSnapshot,
|
||
string? selectedChannelCode)
|
||
{
|
||
var selectedId = evaluation.Selected?.Channel.Id;
|
||
var candidates = evaluation.Candidates
|
||
.Select(c => new OrderAllocationCandidateOutput
|
||
{
|
||
ServiceChannelId = c.Channel.Id,
|
||
ChannelCode = c.Channel.ChannelCode,
|
||
ChannelName = c.Channel.ChannelName,
|
||
TotalPayableCost = c.TotalPayableCost,
|
||
MinTime = c.Channel.MinTime,
|
||
Weight = c.Provider.Weight,
|
||
Selected = selectedId.HasValue && c.Channel.Id == selectedId.Value,
|
||
FeeLines = c.PayableFees.Select(f => new OrderAllocationFeeLineOutput
|
||
{
|
||
CostItem = f.CostItem,
|
||
Amount = f.Amount,
|
||
Currency = f.Currency
|
||
}).ToList()
|
||
})
|
||
.ToList();
|
||
|
||
// 按方案主指标排序,选中置顶
|
||
candidates = SortAllocationCandidates(evaluation.StoredScheme, candidates);
|
||
|
||
return new OrderAllocationTraceOutput
|
||
{
|
||
OrderId = orderId,
|
||
FromSnapshot = fromSnapshot,
|
||
Scheme = evaluation.StoredScheme,
|
||
SchemeName = ResolveAllocationSchemeName(evaluation.StoredScheme),
|
||
SchemeSource = evaluation.SchemeSource,
|
||
SchemeSourceName = ResolveAllocationSchemeSourceName(evaluation.SchemeSource),
|
||
ChargeableWeight = evaluation.ChargeableWeight,
|
||
ZoneKeys = evaluation.ZoneKeys?.ToList() ?? [],
|
||
ChargingStage = evaluation.ChargingStage,
|
||
ChargingStageName = ResolveChargingStageName(evaluation.ChargingStage),
|
||
SelectedChannelCode = selectedChannelCode ?? evaluation.Selected?.Channel.ChannelCode,
|
||
SelectedChannelName = evaluation.Selected?.Channel.ChannelName,
|
||
FailReason = evaluation.FailReason,
|
||
TraceTime = DateTime.Now,
|
||
PoolCount = evaluation.PoolCount,
|
||
ConditionPassCount = evaluation.ConditionPassCount,
|
||
CandidateCount = candidates.Count,
|
||
Candidates = candidates,
|
||
Skipped = evaluation.Skipped
|
||
};
|
||
}
|
||
|
||
private static List<OrderAllocationCandidateOutput> SortAllocationCandidates(
|
||
string? scheme,
|
||
List<OrderAllocationCandidateOutput> candidates)
|
||
{
|
||
Func<OrderAllocationCandidateOutput, decimal> metric = scheme switch
|
||
{
|
||
AllocationSchemeCodes.TimeFastest => x => x.MinTime ?? int.MaxValue,
|
||
AllocationSchemeCodes.OverallBest => x => -x.Weight,
|
||
_ => x => x.TotalPayableCost
|
||
};
|
||
|
||
return candidates
|
||
.OrderByDescending(x => x.Selected)
|
||
.ThenBy(metric)
|
||
.ThenBy(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
}
|
||
|
||
private static string ResolveAllocationSchemeName(string? scheme) =>
|
||
scheme switch
|
||
{
|
||
AllocationSchemeCodes.TimeFastest => "时效优先",
|
||
AllocationSchemeCodes.OverallBest => "综合最优",
|
||
AllocationSchemeCodes.Designated => "指定渠道",
|
||
_ => "价格优先"
|
||
};
|
||
|
||
private static string ResolveAllocationSchemeSourceName(string? source) =>
|
||
source switch
|
||
{
|
||
"reallocate" => "运营重新排货",
|
||
"designated" => "指定渠道",
|
||
_ => "产品默认"
|
||
};
|
||
|
||
private static string ResolveChargingStageName(string? stage) =>
|
||
stage switch
|
||
{
|
||
OrderFeeChargingStageCodes.Inbound => "入库",
|
||
OrderFeeChargingStageCodes.Outbound => "出库",
|
||
OrderFeeChargingStageCodes.Forecast => "预报",
|
||
_ => stage ?? string.Empty
|
||
};
|
||
|
||
private Task ConfirmServiceChannelAndPayableAsync(
|
||
Order order,
|
||
SalesProduct product,
|
||
OrderBillingContext billing,
|
||
string chargingStage,
|
||
string? schemeOverride = null,
|
||
int? forceServiceChannelId = null,
|
||
bool allowDesignatedOutsidePool = false) =>
|
||
ConfirmServiceChannelAndPayableAsync(
|
||
order,
|
||
product,
|
||
billing.SnapshotBinding,
|
||
billing.ZoneKeys,
|
||
billing.ChargeableWeight,
|
||
chargingStage,
|
||
billing.PriceTierKey,
|
||
schemeOverride,
|
||
forceServiceChannelId,
|
||
allowDesignatedOutsidePool);
|
||
|
||
/// <summary>
|
||
/// 尝试排货并写入应付;失败时不抛异常,写入 <see cref="Order.AllocationPendingReason"/> 供运营补配置。
|
||
/// </summary>
|
||
private async Task<bool> TryConfirmServiceChannelAndPayableAsync(
|
||
Order order,
|
||
SalesProduct product,
|
||
OrderBillingContext billing,
|
||
string chargingStage,
|
||
string? schemeOverride = null,
|
||
int? forceServiceChannelId = null,
|
||
bool allowDesignatedOutsidePool = false)
|
||
{
|
||
try
|
||
{
|
||
await ConfirmServiceChannelAndPayableAsync(
|
||
order,
|
||
product,
|
||
billing,
|
||
chargingStage,
|
||
schemeOverride,
|
||
forceServiceChannelId,
|
||
allowDesignatedOutsidePool);
|
||
return true;
|
||
}
|
||
catch (BusinessException ex)
|
||
{
|
||
var message = ex.Message.Trim();
|
||
order.AllocationPendingReason = message.Length > 512 ? message[..512] : message;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private async Task<List<SalesProductAllocationProviderSnapshotDto>> ResolveAllocationProvidersAsync(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto rulesSnapshot)
|
||
{
|
||
List<SalesProductAllocationProviderSnapshotDto> providers;
|
||
if (rulesSnapshot.Providers.Count > 0)
|
||
{
|
||
providers = rulesSnapshot.Providers;
|
||
if (providers.Any(AllocationProviderSnapshotEnricher.NeedsDownstreamEnrichment))
|
||
{
|
||
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var entities = await SalesProductRepository.GetAllocationProvidersAsync(product.Id);
|
||
var channelIds = entities.Where(x => x.ServiceChannelId.HasValue).Select(x => x.ServiceChannelId!.Value).Distinct().ToArray();
|
||
var channels = channelIds.Length == 0
|
||
? []
|
||
: await ServiceChannelRepository.GetListAsync(x => channelIds.Contains(x.Id));
|
||
var channelMap = channels.ToDictionary(x => x.Id, x => x.ChannelName);
|
||
|
||
providers = entities.Select(provider => PublishSnapshotSlimmer.SlimAllocationProvider(new SalesProductAllocationProviderOutput
|
||
{
|
||
Id = provider.Id,
|
||
SalesProductId = provider.SalesProductId,
|
||
SerialNumber = provider.SerialNumber,
|
||
ProviderName = provider.ProviderName,
|
||
ServiceChannelId = provider.ServiceChannelId,
|
||
ServiceChannelName = provider.ServiceChannelId.HasValue && channelMap.TryGetValue(provider.ServiceChannelId.Value, out var name) ? name : null,
|
||
Weight = provider.Weight,
|
||
CustomerIds = OmsDocHelper.DeserializeJson<List<int>>(provider.CustomerIds) ?? [],
|
||
MatchCondition = provider.MatchCondition
|
||
})).ToList();
|
||
await AllocationProviderSnapshotEnricher.EnrichAsync(providers);
|
||
}
|
||
|
||
return providers;
|
||
}
|
||
|
||
private sealed class ChannelPriceBinding
|
||
{
|
||
public List<ServiceChannelPriceListOutput> Prices { get; init; } = [];
|
||
|
||
public string PriceVersion { get; init; } = string.Empty;
|
||
|
||
public DateTime PriceEffectiveTime { get; init; }
|
||
|
||
public ServiceChannelPricePublishSnapshotDto Snapshot { get; init; } = new();
|
||
}
|
||
|
||
private sealed class ChannelRulesBinding
|
||
{
|
||
public int? RemoteAreaId { get; init; }
|
||
|
||
public ServiceChannelRulesPublishSnapshotDto Snapshot { get; init; } = new();
|
||
}
|
||
|
||
private async Task<ChannelPriceBinding> ResolveChannelPriceBindingAsync(int serviceChannelId, DateTime bindTime)
|
||
{
|
||
var priceRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync(
|
||
serviceChannelId,
|
||
PriceOperatorType,
|
||
bindTime);
|
||
if (priceRecord?.Content != null)
|
||
{
|
||
var snapshot = OmsDocHelper.DeserializeChannelPriceSnapshot(priceRecord.Content);
|
||
if (snapshot?.Prices.Count > 0)
|
||
{
|
||
await KpiAreaPackService.HydrateChannelPriceSnapshotAsync(snapshot);
|
||
return new ChannelPriceBinding
|
||
{
|
||
Prices = snapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(),
|
||
PriceVersion = priceRecord.Version,
|
||
PriceEffectiveTime = priceRecord.PublishTime,
|
||
Snapshot = snapshot
|
||
};
|
||
}
|
||
}
|
||
|
||
var liveSnapshot = await BuildLiveChannelPriceSnapshotAsync(serviceChannelId);
|
||
return new ChannelPriceBinding
|
||
{
|
||
Prices = liveSnapshot.Prices.Select(PublishSnapshotSlimmer.ToChannelPriceListOutput).ToList(),
|
||
PriceVersion = $"LIVE-{serviceChannelId}",
|
||
PriceEffectiveTime = bindTime,
|
||
Snapshot = liveSnapshot
|
||
};
|
||
}
|
||
|
||
private async Task<ChannelRulesBinding> ResolveChannelRulesBindingAsync(
|
||
ServiceChannel channel,
|
||
DateTime bindTime)
|
||
{
|
||
var rulesRecord = await ServiceChannelRepository.GetEffectivePublishRecordAsync(
|
||
channel.Id,
|
||
RulesOperatorType,
|
||
bindTime);
|
||
if (rulesRecord?.Content != null)
|
||
{
|
||
var snapshot = OmsDocHelper.DeserializeJson<ServiceChannelRulesPublishSnapshotDto>(rulesRecord.Content);
|
||
if (snapshot != null)
|
||
{
|
||
await KpiAreaPackService.HydrateChannelRulesSnapshotAsync(snapshot);
|
||
return new ChannelRulesBinding
|
||
{
|
||
RemoteAreaId = snapshot.RemoteAreaRef?.AreaId
|
||
?? snapshot.RemoteAreaId
|
||
?? snapshot.Channel?.RemoteAreaId
|
||
?? channel.RemoteAreaId,
|
||
Snapshot = snapshot
|
||
};
|
||
}
|
||
}
|
||
|
||
var live = new ServiceChannelRulesPublishSnapshotDto
|
||
{
|
||
Channel = new ServiceChannelSaveInput
|
||
{
|
||
Id = channel.Id,
|
||
RemoteAreaId = channel.RemoteAreaId
|
||
},
|
||
RemoteAreaInfos = []
|
||
};
|
||
await KpiAreaPackService.LoadLiveRemoteIntoChannelRulesSnapshotAsync(
|
||
live,
|
||
channel.RemoteAreaId);
|
||
return new ChannelRulesBinding
|
||
{
|
||
RemoteAreaId = channel.RemoteAreaId,
|
||
Snapshot = live
|
||
};
|
||
}
|
||
|
||
private async Task<ServiceChannelPricePublishSnapshotDto> BuildLiveChannelPriceSnapshotAsync(
|
||
int serviceChannelId)
|
||
{
|
||
var snapshot = new ServiceChannelPricePublishSnapshotDto
|
||
{
|
||
ServiceChannelId = serviceChannelId,
|
||
Prices = (await BuildChannelPriceOutputsAsync(serviceChannelId))
|
||
.Select(PublishSnapshotSlimmer.SlimChannelPrice)
|
||
.ToList()
|
||
};
|
||
await PriceAreaSnapshotHelper.ApplyLiveChannelPriceAreaInfosAsync(
|
||
snapshot,
|
||
KpiAreaRepository,
|
||
KpiAreaPackService);
|
||
return snapshot;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按渠道买价价区包解析 zoneKeys;有价区配置但未命中时返回 null(跳过该渠道)。
|
||
/// 无价区配置时返回空列表(不回落产品卖价分区)。
|
||
/// </summary>
|
||
private static IReadOnlyList<string>? ResolveChannelZoneKeysOrSkip(
|
||
ChannelPriceBinding binding,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode,
|
||
out string? missReason)
|
||
{
|
||
missReason = null;
|
||
var snapshot = binding.Snapshot;
|
||
if (snapshot.PriceAreaRefs is { Count: > 0 }
|
||
&& snapshot.PriceAreaInfos is not { Count: > 0 })
|
||
{
|
||
missReason =
|
||
$"渠道价格分区包未加载: refs={snapshot.PriceAreaRefs.Count} version={binding.PriceVersion}";
|
||
return null;
|
||
}
|
||
|
||
if (snapshot.PriceAreaInfos is not { Count: > 0 })
|
||
{
|
||
return Array.Empty<string>();
|
||
}
|
||
|
||
var keys = ParcelBillingCalculator.TryResolveZoneKeysFromChannelPriceSnapshot(
|
||
snapshot,
|
||
countryCode,
|
||
province,
|
||
city,
|
||
zipCode);
|
||
if (keys == null || keys.Count == 0)
|
||
{
|
||
missReason = $"渠道买价分区未匹配(版本 {binding.PriceVersion})";
|
||
return null;
|
||
}
|
||
|
||
return keys;
|
||
}
|
||
|
||
private static bool IsChannelRemoteHit(
|
||
ChannelRulesBinding binding,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode)
|
||
{
|
||
var snapshot = binding.Snapshot;
|
||
if (snapshot.RemoteAreaRef != null && snapshot.RemoteAreaInfos is not { Count: > 0 })
|
||
{
|
||
throw new BusinessException(
|
||
$"渠道偏远分区包未加载: areaId={snapshot.RemoteAreaRef.AreaId} version={snapshot.RemoteAreaRef.PackVersion}");
|
||
}
|
||
|
||
if (snapshot.RemoteAreaInfos is { Count: > 0 })
|
||
{
|
||
return GeoPartitionMatcher.IsHit(
|
||
snapshot.RemoteAreaInfos,
|
||
countryCode,
|
||
province,
|
||
city,
|
||
zipCode);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private async Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
|
||
{
|
||
var snapshot = new SalesProductRulesPublishSnapshotDto
|
||
{
|
||
Product = new SalesProductSaveInput
|
||
{
|
||
Id = product.Id,
|
||
ProductCode = product.ProductCode,
|
||
ProductName = product.ProductName,
|
||
BlacklistAreaId = product.BlacklistAreaId,
|
||
RemoteAreaId = product.RemoteAreaId,
|
||
PriceAreaId = product.PriceAreaId,
|
||
WeightRuleId = product.WeightRuleId,
|
||
WeightRoundingId = product.WeightRoundingId,
|
||
IsActive = product.IsActive,
|
||
Scheme = product.Scheme
|
||
}
|
||
};
|
||
await RulesSnapshotHelper.ApplyLiveRulesToSnapshotAsync(
|
||
snapshot,
|
||
product,
|
||
KpiAreaRepository,
|
||
WeightRuleRepository,
|
||
WeightRoundingRepository,
|
||
KpiAreaPackService);
|
||
snapshot.Providers = await ResolveAllocationProvidersAsync(product, snapshot);
|
||
return snapshot;
|
||
}
|
||
|
||
private async Task<SalesProductPricePublishSnapshotDto> BuildLivePriceSnapshotAsync(SalesProduct product)
|
||
{
|
||
var prices = await SalesProductRepository.GetPricesAsync(product.Id);
|
||
var priceIds = prices.Select(x => x.Id).ToArray();
|
||
var factorMap = (await SalesProductRepository.GetPriceFactorsAsync(priceIds))
|
||
.GroupBy(x => x.PriceId)
|
||
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList());
|
||
var detailMap = (await SalesProductRepository.GetPriceDetailsAsync(priceIds))
|
||
.GroupBy(x => x.PriceId)
|
||
.ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList());
|
||
|
||
var snapshot = new SalesProductPricePublishSnapshotDto
|
||
{
|
||
SalesProductId = product.Id,
|
||
Prices = prices.Select(price => PublishSnapshotSlimmer.SlimPrice(new SalesProductPriceListOutput
|
||
{
|
||
Id = price.Id,
|
||
SalesProductId = price.SalesProductId,
|
||
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(MapPriceFactorOutput).ToList()
|
||
: [],
|
||
Details = detailMap.TryGetValue(price.Id, out var details)
|
||
? details.Select(MapPriceDetailOutput).ToList()
|
||
: []
|
||
})).ToList()
|
||
};
|
||
await PriceAreaSnapshotHelper.ApplyLivePriceAreaInfosAsync(
|
||
snapshot,
|
||
product,
|
||
KpiAreaRepository,
|
||
KpiAreaPackService);
|
||
return snapshot;
|
||
}
|
||
|
||
private static ChannelPriceFactorOutput MapPriceFactorOutput(SalesProductPriceFactor entity)
|
||
{
|
||
return new ChannelPriceFactorOutput
|
||
{
|
||
Id = entity.Id,
|
||
PriceId = entity.PriceId,
|
||
SerialNumber = entity.SerialNumber,
|
||
Factor = entity.Factor,
|
||
FactorValues = OmsDocHelper.DeserializeJson<List<string>>(entity.FactorValues) ?? []
|
||
};
|
||
}
|
||
|
||
private static ChannelPriceDetailOutput MapPriceDetailOutput(SalesProductPriceDetail entity)
|
||
{
|
||
return new ChannelPriceDetailOutput
|
||
{
|
||
Id = entity.Id,
|
||
PriceId = entity.PriceId,
|
||
FactorValuesJson = OmsDocHelper.DeserializeJsonObject(entity.FactorValuesJson),
|
||
CalculationFormula = entity.CalculationFormula,
|
||
CalculatedValue = entity.CalculatedValue,
|
||
CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(entity.CustomerGroupValues)
|
||
};
|
||
}
|
||
|
||
private Task<bool> IsBlacklistHitAsync(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode)
|
||
{
|
||
if (rulesSnapshot?.BlacklistAreaRef != null && rulesSnapshot.BlacklistAreaInfos is not { Count: > 0 })
|
||
{
|
||
throw new BusinessException(
|
||
$"黑名单分区包未加载: areaId={rulesSnapshot.BlacklistAreaRef.AreaId} version={rulesSnapshot.BlacklistAreaRef.PackVersion}");
|
||
}
|
||
|
||
if (rulesSnapshot?.BlacklistAreaInfos is { Count: > 0 })
|
||
{
|
||
return Task.FromResult(GeoPartitionMatcher.IsHit(
|
||
rulesSnapshot.BlacklistAreaInfos,
|
||
countryCode,
|
||
province,
|
||
city,
|
||
zipCode));
|
||
}
|
||
|
||
return Task.FromResult(false);
|
||
}
|
||
|
||
private Task<bool> IsRemoteHitAsync(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode)
|
||
{
|
||
if (rulesSnapshot?.RemoteAreaRef != null && rulesSnapshot.RemoteAreaInfos is not { Count: > 0 })
|
||
{
|
||
throw new BusinessException(
|
||
$"偏远分区包未加载: areaId={rulesSnapshot.RemoteAreaRef.AreaId} version={rulesSnapshot.RemoteAreaRef.PackVersion}");
|
||
}
|
||
|
||
if (rulesSnapshot?.RemoteAreaInfos is { Count: > 0 })
|
||
{
|
||
return Task.FromResult(GeoPartitionMatcher.IsHit(
|
||
rulesSnapshot.RemoteAreaInfos,
|
||
countryCode,
|
||
province,
|
||
city,
|
||
zipCode));
|
||
}
|
||
|
||
return Task.FromResult(false);
|
||
}
|
||
|
||
private static int? ResolveEffectiveBlacklistAreaId(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||
{
|
||
if (product.BlacklistAreaId is > 0)
|
||
{
|
||
return product.BlacklistAreaId;
|
||
}
|
||
|
||
if (rulesSnapshot?.BlacklistAreaId is > 0)
|
||
{
|
||
return rulesSnapshot.BlacklistAreaId;
|
||
}
|
||
|
||
if (rulesSnapshot?.Product?.BlacklistAreaId is > 0)
|
||
{
|
||
return rulesSnapshot.Product.BlacklistAreaId;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static int? ResolveEffectiveRemoteAreaId(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||
{
|
||
if (product.RemoteAreaId is > 0)
|
||
{
|
||
return product.RemoteAreaId;
|
||
}
|
||
|
||
if (rulesSnapshot?.RemoteAreaId is > 0)
|
||
{
|
||
return rulesSnapshot.RemoteAreaId;
|
||
}
|
||
|
||
if (rulesSnapshot?.Product?.RemoteAreaId is > 0)
|
||
{
|
||
return rulesSnapshot.Product.RemoteAreaId;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static int? ResolveEffectivePriceAreaId(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
SalesProductPricePublishSnapshotDto? priceSnapshot)
|
||
{
|
||
if (product.PriceAreaId is > 0)
|
||
{
|
||
return product.PriceAreaId;
|
||
}
|
||
|
||
if (rulesSnapshot?.Product?.PriceAreaId is > 0)
|
||
{
|
||
return rulesSnapshot.Product.PriceAreaId;
|
||
}
|
||
|
||
if (priceSnapshot?.PriceAreaId is > 0)
|
||
{
|
||
return priceSnapshot.PriceAreaId;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private Task<IReadOnlyList<string>?> ResolvePriceZoneKeysAsync(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
SalesProductPricePublishSnapshotDto? priceSnapshot,
|
||
string countryCode,
|
||
string? province,
|
||
string? city,
|
||
string? zipCode)
|
||
{
|
||
if (priceSnapshot?.PriceAreaRefs is { Count: > 0 }
|
||
&& priceSnapshot.PriceAreaInfos is not { Count: > 0 })
|
||
{
|
||
throw new BusinessException("价格分区包未加载,无法匹配分区");
|
||
}
|
||
|
||
if (priceSnapshot?.PriceAreaInfos is { Count: > 0 })
|
||
{
|
||
return Task.FromResult(ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot(
|
||
priceSnapshot,
|
||
countryCode,
|
||
province,
|
||
city,
|
||
zipCode));
|
||
}
|
||
|
||
// 无分区配置:返回 null;禁止回读线上 KPI(正式路径须走包)
|
||
return Task.FromResult<IReadOnlyList<string>?>(null);
|
||
}
|
||
|
||
private async Task ApplyChargeableWeightAsync(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
OrderWeight forecast)
|
||
{
|
||
try
|
||
{
|
||
var weightRuleId = ResolveEffectiveWeightRuleId(product, rulesSnapshot);
|
||
if (!weightRuleId.HasValue)
|
||
{
|
||
throw new BusinessException("未配置计重规则");
|
||
}
|
||
|
||
IReadOnlyList<WeightRuleInfo> ruleInfos;
|
||
if (rulesSnapshot?.WeightRuleInfos is { Count: > 0 })
|
||
{
|
||
ruleInfos = RulesSnapshotHelper.ToWeightRuleEntities(rulesSnapshot.WeightRuleInfos);
|
||
}
|
||
else
|
||
{
|
||
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == weightRuleId.Value);
|
||
if (weightRule == null)
|
||
{
|
||
throw new BusinessException("未配置计重规则");
|
||
}
|
||
|
||
ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(weightRuleId.Value);
|
||
}
|
||
|
||
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
|
||
forecast.VolumetricWeight = volumetricWeight;
|
||
|
||
var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot);
|
||
if (!weightRoundingId.HasValue)
|
||
{
|
||
throw new BusinessException("未配置计重进位");
|
||
}
|
||
|
||
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
||
var dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||
|
||
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
||
rawWeight,
|
||
ranges,
|
||
rulesSnapshot,
|
||
weightRoundingId.Value,
|
||
dbRanges);
|
||
}
|
||
catch (BusinessException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
throw new BusinessException(ex.Message);
|
||
}
|
||
}
|
||
|
||
private async Task<IReadOnlyList<WeightRoundingInfo>> ResolveWeightRoundingRangesAsync(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
int weightRoundingId)
|
||
{
|
||
var snapshotRanges = rulesSnapshot?.WeightRoundingInfos;
|
||
var fromSnapshot = snapshotRanges is { Count: > 0 }
|
||
&& snapshotRanges.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
||
if (fromSnapshot)
|
||
{
|
||
return RulesSnapshotHelper.ToWeightRoundingEntities(snapshotRanges!);
|
||
}
|
||
|
||
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == weightRoundingId);
|
||
if (rounding == null)
|
||
{
|
||
throw new BusinessException("未配置计重进位");
|
||
}
|
||
|
||
return await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId);
|
||
}
|
||
|
||
private static decimal ApplyRoundingWithSnapshotFallback(
|
||
decimal rawWeight,
|
||
IReadOnlyList<WeightRoundingInfo> ranges,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
int weightRoundingId,
|
||
IReadOnlyList<WeightRoundingInfo> dbFallbackRanges)
|
||
{
|
||
var fromSnapshot = rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
||
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public));
|
||
try
|
||
{
|
||
return ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
|
||
}
|
||
catch (InvalidOperationException) when (fromSnapshot && dbFallbackRanges.Count > 0)
|
||
{
|
||
return ParcelBillingCalculator.ApplyRounding(rawWeight, dbFallbackRanges);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查价专用:无尺寸时仅按实重进位计计费重(不算体积重)。
|
||
/// </summary>
|
||
private async Task ApplyQuoteChargeableWeightFromActualOnlyAsync(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot,
|
||
OrderWeight forecast)
|
||
{
|
||
var actualWeight = forecast.ActualWeight ?? 0;
|
||
if (actualWeight <= 0)
|
||
{
|
||
throw new BusinessException("货物重量无效");
|
||
}
|
||
|
||
forecast.VolumetricWeight = 0;
|
||
forecast.Cubic = null;
|
||
|
||
var weightRoundingId = ResolveEffectiveWeightRoundingId(product, rulesSnapshot);
|
||
if (!weightRoundingId.HasValue)
|
||
{
|
||
throw new BusinessException("未配置计重进位");
|
||
}
|
||
|
||
var ranges = await ResolveWeightRoundingRangesAsync(product, rulesSnapshot, weightRoundingId.Value);
|
||
var dbRanges = ranges;
|
||
if (rulesSnapshot?.WeightRoundingInfos is { Count: > 0 }
|
||
&& rulesSnapshot.WeightRoundingInfos.Any(x => !string.IsNullOrWhiteSpace(x.Public)))
|
||
{
|
||
dbRanges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(weightRoundingId.Value);
|
||
}
|
||
|
||
try
|
||
{
|
||
forecast.ChargeableWeight = ApplyRoundingWithSnapshotFallback(
|
||
actualWeight,
|
||
ranges,
|
||
rulesSnapshot,
|
||
weightRoundingId.Value,
|
||
dbRanges);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
throw new BusinessException(ex.Message);
|
||
}
|
||
}
|
||
|
||
private static void EnsureBillingProductActive(SalesProduct product)
|
||
{
|
||
if (product.IsActive != EnableStatus.Enabled)
|
||
{
|
||
throw new BusinessException("订单关联的销售产品未启用,无法计费");
|
||
}
|
||
}
|
||
|
||
private static void ApplyMeasuredDimensions(
|
||
OrderWeight weightRow,
|
||
decimal length,
|
||
decimal width,
|
||
decimal height,
|
||
decimal actualWeight)
|
||
{
|
||
weightRow.Length = length;
|
||
weightRow.Width = width;
|
||
weightRow.Height = height;
|
||
weightRow.ActualWeight = actualWeight;
|
||
weightRow.Cubic = ParcelBillingCalculator.CalculateCubic(length, width, height);
|
||
}
|
||
|
||
private static int? ResolveEffectiveWeightRuleId(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||
{
|
||
if (product.WeightRuleId is > 0)
|
||
{
|
||
return product.WeightRuleId;
|
||
}
|
||
|
||
if (rulesSnapshot?.WeightRuleId is > 0)
|
||
{
|
||
return rulesSnapshot.WeightRuleId;
|
||
}
|
||
|
||
if (rulesSnapshot?.Product?.WeightRuleId is > 0)
|
||
{
|
||
return rulesSnapshot.Product.WeightRuleId;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static int? ResolveEffectiveWeightRoundingId(
|
||
SalesProduct product,
|
||
SalesProductRulesPublishSnapshotDto? rulesSnapshot)
|
||
{
|
||
if (product.WeightRoundingId is > 0)
|
||
{
|
||
return product.WeightRoundingId;
|
||
}
|
||
|
||
if (rulesSnapshot?.WeightRoundingId is > 0)
|
||
{
|
||
return rulesSnapshot.WeightRoundingId;
|
||
}
|
||
|
||
if (rulesSnapshot?.Product?.WeightRoundingId is > 0)
|
||
{
|
||
return rulesSnapshot.Product.WeightRoundingId;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static List<OrderWeight> BuildInitialWeightRows(int orderId, OrderParcelCreateInput input)
|
||
{
|
||
var cubic = ParcelBillingCalculator.CalculateCubic(input.Length!.Value, input.Width!.Value, input.Height!.Value);
|
||
var forecast = new OrderWeight
|
||
{
|
||
OrderId = orderId,
|
||
Type = OrderWeightTypeCodes.Forecast,
|
||
Length = input.Length,
|
||
Width = input.Width,
|
||
Height = input.Height,
|
||
ActualWeight = input.ActualWeight,
|
||
VolumetricWeight = input.VolumetricWeight ?? 0,
|
||
ChargeableWeight = input.ChargeableWeight ?? input.ActualWeight,
|
||
Cubic = input.Cubic ?? cubic
|
||
};
|
||
|
||
return
|
||
[
|
||
forecast,
|
||
CloneWeightRow(orderId, OrderWeightTypeCodes.Receive, forecast),
|
||
CloneWeightRow(orderId, OrderWeightTypeCodes.Ship, forecast),
|
||
CloneWeightRow(orderId, OrderWeightTypeCodes.Downstream, forecast)
|
||
];
|
||
}
|
||
|
||
private static OrderWeight CloneWeightRow(int orderId, string type, OrderWeight source) =>
|
||
new()
|
||
{
|
||
OrderId = orderId,
|
||
Type = type,
|
||
Length = source.Length,
|
||
Width = source.Width,
|
||
Height = source.Height,
|
||
ActualWeight = source.ActualWeight,
|
||
VolumetricWeight = source.VolumetricWeight,
|
||
ChargeableWeight = source.ChargeableWeight,
|
||
Cubic = source.Cubic
|
||
};
|
||
|
||
private static void SyncWeightRows(List<OrderWeight> weights, OrderWeight forecast)
|
||
{
|
||
foreach (var weight in weights)
|
||
{
|
||
weight.Length = forecast.Length;
|
||
weight.Width = forecast.Width;
|
||
weight.Height = forecast.Height;
|
||
weight.ActualWeight = forecast.ActualWeight;
|
||
weight.VolumetricWeight = forecast.VolumetricWeight;
|
||
weight.ChargeableWeight = forecast.ChargeableWeight;
|
||
weight.Cubic = forecast.Cubic;
|
||
}
|
||
}
|
||
|
||
private static Order BuildDraftOrderEntity(
|
||
OrderParcelCreateInput input,
|
||
SalesProduct product,
|
||
string consigneeCountry,
|
||
string consigneeCountryName,
|
||
ProductSnapshotBinding snapshotBinding,
|
||
string serviceNo,
|
||
ParcelCreateContext? createContext = null)
|
||
{
|
||
var orderSource = string.IsNullOrWhiteSpace(createContext?.OrderSource)
|
||
? OrderSourceCodes.Manual
|
||
: createContext!.OrderSource!.Trim();
|
||
|
||
return new Order
|
||
{
|
||
RequestId = input.RequestId,
|
||
ClientNo = input.ClientNo,
|
||
TrackNo = input.TrackNo,
|
||
ServiceNo = serviceNo,
|
||
ProductCode = input.ProductCode,
|
||
ChannelCode = input.ChannelCode ?? product.ProductCode,
|
||
ConsigneeCountry = consigneeCountry,
|
||
ConsigneeCountryName = consigneeCountryName,
|
||
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
|
||
ExportCustomsMode = input.ExportCustomsMode,
|
||
ExportQualificationId = input.ExportQualificationId,
|
||
WarehouseId = input.WarehouseId,
|
||
CustomerId = input.CustomerId,
|
||
OrderSource = orderSource,
|
||
PickupTime = createContext?.PickupTime,
|
||
CustomerSenderAddressId = createContext?.CustomerSenderAddressId,
|
||
InboundMethod = string.IsNullOrWhiteSpace(createContext?.InboundMethod)
|
||
? null
|
||
: createContext!.InboundMethod!.Trim(),
|
||
IsCod = input.IsCod ? (byte)1 : (byte)0,
|
||
CodCurrency = input.CodCurrency,
|
||
CodSum = input.CodSum,
|
||
DeclaredCurrency = input.DeclaredCurrency,
|
||
DeclaredSum = input.DeclaredSum,
|
||
Quantity = input.Quantity,
|
||
IsIntercept = 0,
|
||
IsRemote = 0,
|
||
AdditionalServices = SerializeAdditionalServices(input.AdditionalServices),
|
||
FulfillmentType = FulfillmentTypeCodes.Direct,
|
||
PredeclareFailReason = string.Empty,
|
||
AllocationPendingReason = string.Empty,
|
||
SalesProductRulesVersion = snapshotBinding.RulesVersion,
|
||
SalesProductPriceVersion = snapshotBinding.PriceVersion,
|
||
LabelTemplateId = product.LabelTemplateId,
|
||
OrderStatus = OrderStatusCodes.Draft
|
||
};
|
||
}
|
||
|
||
private async Task SaveParcelChildrenAsync(
|
||
Order order,
|
||
OrderParcelCreateInput input,
|
||
List<OrderWeight> weights,
|
||
Country receiverCountry)
|
||
{
|
||
if (input.Sender != null)
|
||
{
|
||
int? senderCountryId = null;
|
||
if (!string.IsNullOrWhiteSpace(input.Sender.CountryCode))
|
||
{
|
||
senderCountryId = (await GetCountryOrThrowAsync(input.Sender.CountryCode, "发件国家不存在")).Id;
|
||
}
|
||
|
||
var sender = await OrderRepository.InsertSenderAsync(new OrderSender
|
||
{
|
||
OrderId = order.Id,
|
||
SenderName = input.Sender.SenderName,
|
||
Company = input.Sender.Company,
|
||
Country = senderCountryId,
|
||
Province = input.Sender.Province,
|
||
City = input.Sender.City,
|
||
ZipCode = input.Sender.ZipCode,
|
||
StreetAddress1 = input.Sender.StreetAddress1,
|
||
StreetAddress2 = input.Sender.StreetAddress2,
|
||
StreetAddress3 = input.Sender.StreetAddress3,
|
||
HouseNumber = input.Sender.HouseNumber,
|
||
Phone = input.Sender.Phone,
|
||
TelePhone = input.Sender.TelePhone,
|
||
Email = input.Sender.Email,
|
||
IsDefault = 0
|
||
});
|
||
order.SenderId = sender.Id;
|
||
}
|
||
|
||
if (input.Receiver != null)
|
||
{
|
||
var recipient = await OrderRepository.InsertRecipientAsync(new OrderRecipient
|
||
{
|
||
OrderId = order.Id,
|
||
RecipientsName = input.Receiver.RecipientsName,
|
||
CompanyName = input.Receiver.CompanyName,
|
||
Country = receiverCountry.Id,
|
||
Phone = input.Receiver.Phone,
|
||
TelePhone = input.Receiver.TelePhone,
|
||
ZipCode = input.Receiver.ZipCode,
|
||
Province = input.Receiver.Province,
|
||
City = input.Receiver.City,
|
||
StreetNumber = input.Receiver.StreetNumber,
|
||
StreetAddress1 = input.Receiver.StreetAddress1,
|
||
StreetAddress2 = input.Receiver.StreetAddress2,
|
||
StreetAddress3 = input.Receiver.StreetAddress3,
|
||
Email = input.Receiver.Email,
|
||
TaxType = input.Receiver.TaxType,
|
||
TaxNo = input.Receiver.TaxNo,
|
||
CertificateType = input.Receiver.CertificateType,
|
||
CertificateNo = input.Receiver.CertificateNo,
|
||
Eori = input.Receiver.Eori,
|
||
Vat = input.Receiver.Vat
|
||
});
|
||
order.RecipientId = recipient.Id;
|
||
}
|
||
|
||
if (input.Items.Count > 0)
|
||
{
|
||
await OrderRepository.InsertDeclarationsAsync(input.Items.Select(x => new OrderDeclaration
|
||
{
|
||
OrderId = order.Id,
|
||
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,
|
||
DeclareElement = x.DeclareElement,
|
||
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
|
||
}));
|
||
}
|
||
|
||
await OrderRepository.InsertWeightsAsync(weights);
|
||
await OrderRepository.UpdateAsync(order);
|
||
}
|
||
|
||
private async Task<string> ResolveServiceNoAsync()
|
||
{
|
||
for (var i = 0; i < 5; i++)
|
||
{
|
||
var generated = BuildParcelServiceNo(DateTime.Now, Random.Shared.Next(1000, 9999));
|
||
if (!await OrderRepository.HasServiceNoAsync(generated))
|
||
{
|
||
return generated;
|
||
}
|
||
}
|
||
|
||
throw new BusinessException("运单号生成失败");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。
|
||
/// </summary>
|
||
internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
||
{
|
||
throw new BusinessException("订单未绑定销售产品,无法预报");
|
||
}
|
||
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||
if (product == null || product.IsActive != EnableStatus.Enabled)
|
||
{
|
||
throw new BusinessException("销售产品不存在或未启用");
|
||
}
|
||
|
||
try
|
||
{
|
||
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||
var address = await ResolveOrderBillingAddressAsync(order);
|
||
|
||
if (await IsBlacklistHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
address.CountryCode,
|
||
address.Province,
|
||
address.City,
|
||
address.ZipCode))
|
||
{
|
||
throw new BusinessException("收件地址命中黑名单区域");
|
||
}
|
||
|
||
var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]);
|
||
if (!weights.Any(x =>
|
||
string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
throw new BusinessException("订单缺少预报件重尺");
|
||
}
|
||
|
||
order.OrderStatus = OrderStatusCodes.Draft;
|
||
order.PredeclareFailReason = ForecastBillingProcessingReason;
|
||
order.AllocationPendingReason = string.Empty;
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||
var authorization = CaptureRequestAuthorization();
|
||
await EnqueueForecastBillingAsync(order, order.CouponInstanceId, operatorId, authorization);
|
||
await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent
|
||
{
|
||
OrderId = order.Id,
|
||
OperatorUserId = operatorId
|
||
});
|
||
await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId);
|
||
|
||
// 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。
|
||
var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id);
|
||
if (refreshed != null
|
||
&& string.Equals(refreshed.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase)
|
||
&& !string.IsNullOrWhiteSpace(refreshed.PredeclareFailReason)
|
||
&& !string.Equals(
|
||
refreshed.PredeclareFailReason,
|
||
ForecastBillingProcessingReason,
|
||
StringComparison.Ordinal))
|
||
{
|
||
throw CreateDraftSavedBusinessException(
|
||
refreshed,
|
||
StripDraftSavedMarker(refreshed.PredeclareFailReason));
|
||
}
|
||
}
|
||
catch (BusinessException ex)
|
||
{
|
||
if (string.Equals(ex.ErrorCode, OrderParcelDraftFailData.ErrorCodeValue, StringComparison.Ordinal))
|
||
{
|
||
throw;
|
||
}
|
||
|
||
var cleanReason = StripDraftSavedMarker(ex.Message);
|
||
if (!string.Equals(cleanReason, ForecastBillingProcessingReason, StringComparison.Ordinal))
|
||
{
|
||
await MarkParcelDraftFailedAsync(order, cleanReason);
|
||
}
|
||
|
||
throw CreateDraftSavedBusinessException(order, cleanReason);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var cleanReason = StripDraftSavedMarker(ex.Message);
|
||
await MarkParcelDraftFailedAsync(order, cleanReason);
|
||
throw CreateDraftSavedBusinessException(order, cleanReason);
|
||
}
|
||
}
|
||
|
||
private async Task MarkParcelDraftFailedAsync(Order order, string reason)
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.Draft;
|
||
order.PredeclareFailReason = reason;
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 入库收货:同步只写入 receive 件重尺与收货时间,状态先置待排货;
|
||
/// 应收/应付/排货走异步任务,失败不回滚入库。
|
||
/// </summary>
|
||
internal async Task ApplyReceiveBillingByWaybillAsync(
|
||
string waybillNo,
|
||
decimal? length,
|
||
decimal? width,
|
||
decimal? height,
|
||
decimal? actualWeight)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(waybillNo))
|
||
{
|
||
throw new BusinessException("运单号不能为空");
|
||
}
|
||
|
||
ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "收货");
|
||
|
||
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
|
||
if (order == null)
|
||
{
|
||
throw new BusinessException("未找到对应订单,请检查单号");
|
||
}
|
||
|
||
ParcelBillingValidators.EnsureInboundOrderStatus(order.OrderStatus);
|
||
|
||
if (!InboundBillingInFlight.TryAdd(order.Id, 0))
|
||
{
|
||
throw new BusinessException("订单入库处理中,请勿重复扫描");
|
||
}
|
||
|
||
try
|
||
{
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
|
||
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
||
.OrderBy(x => x.Id)
|
||
.ToList();
|
||
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
|
||
if (receive == null)
|
||
{
|
||
throw new BusinessException("订单缺少收货件重尺记录");
|
||
}
|
||
|
||
ApplyMeasuredDimensions(receive, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
|
||
|
||
// 尽力算计费重,失败留给异步计费重试,不阻断入库扫码。
|
||
try
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(order.ProductCode))
|
||
{
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||
if (product != null)
|
||
{
|
||
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = InboundBillingProcessingReason;
|
||
order.ReceiveTime = DateTime.Now;
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
// 操作日志同步落库(件重尺已写完);计费/排货仍走 MQ,不把审计塞进队列
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.Inbound,
|
||
beforeSnap,
|
||
afterSnap,
|
||
FormatMeasuredDimensionsRemark("收货", length!.Value, width!.Value, height!.Value, actualWeight!.Value));
|
||
|
||
await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.InboundTrackCode);
|
||
await EnqueueInboundBillingAsync(order);
|
||
}
|
||
finally
|
||
{
|
||
InboundBillingInFlight.TryRemove(order.Id, out _);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 入库异步计费:重算 inbound 应收 + 尝试排货写应付;失败仅落待排货原因。
|
||
/// </summary>
|
||
internal async Task ProcessInboundBillingForBatchAsync(
|
||
int orderId,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
_ = cancellationToken;
|
||
var order = await GetOrderOrThrowAsync(orderId);
|
||
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = "订单未绑定销售产品,无法入库计费";
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
return;
|
||
}
|
||
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||
if (product == null)
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = "订单关联的销售产品不存在";
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
EnsureBillingProductActive(product);
|
||
}
|
||
catch (BusinessException ex)
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = TrimPendingReason(ex.Message);
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
return;
|
||
}
|
||
|
||
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
||
.OrderBy(x => x.Id)
|
||
.ToList();
|
||
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
|
||
if (receive == null)
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = "订单缺少收货件重尺记录";
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
return;
|
||
}
|
||
|
||
ProductSnapshotBinding snapshotBinding;
|
||
try
|
||
{
|
||
snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive);
|
||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = TrimPendingReason(
|
||
ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}");
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
return;
|
||
}
|
||
|
||
OrderBillingContext billing;
|
||
try
|
||
{
|
||
billing = await ResolveOrderBillingContextAsync(
|
||
order,
|
||
product,
|
||
receive.ChargeableWeight ?? 0,
|
||
snapshotBinding);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = TrimPendingReason(
|
||
ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}");
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
return;
|
||
}
|
||
|
||
var (receivableOk, receivableFailReason) = await TryUpsertInboundReceivableFeesAsync(order, billing);
|
||
var allocated = await TryConfirmServiceChannelAndPayableAsync(
|
||
order,
|
||
product,
|
||
billing,
|
||
OrderFeeChargingStageCodes.Inbound);
|
||
|
||
try
|
||
{
|
||
await RefreshMarketingPayableAmountAsync(order);
|
||
}
|
||
catch
|
||
{
|
||
// 营销金额刷新失败不影响入库结果
|
||
}
|
||
|
||
if (allocated && receivableOk)
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingPayment;
|
||
order.AllocationPendingReason = string.Empty;
|
||
}
|
||
else
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
// 排货成功会清空 AllocationPendingReason,需用应收失败原因回填
|
||
if (!receivableOk && !string.IsNullOrWhiteSpace(receivableFailReason))
|
||
{
|
||
order.AllocationPendingReason = receivableFailReason;
|
||
}
|
||
else if (string.IsNullOrWhiteSpace(order.AllocationPendingReason))
|
||
{
|
||
order.AllocationPendingReason = allocated
|
||
? "入库应收未匹配到有效卖价"
|
||
: "排货未匹配到可用服务渠道或买价";
|
||
}
|
||
}
|
||
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
if (allocated)
|
||
{
|
||
var customer = order.CustomerId.HasValue
|
||
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
|
||
: null;
|
||
await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null);
|
||
}
|
||
}
|
||
|
||
private async Task<(bool Ok, string? FailReason)> TryUpsertInboundReceivableFeesAsync(
|
||
Order order,
|
||
OrderBillingContext billing)
|
||
{
|
||
try
|
||
{
|
||
await OrderRepository.DeleteReceivableFeesAsync(order.Id);
|
||
await UpsertReceivableFeesAsync(
|
||
order,
|
||
billing,
|
||
OrderFeeChargingStageCodes.Inbound);
|
||
return (true, null);
|
||
}
|
||
catch (BusinessException ex)
|
||
{
|
||
return (false, TrimPendingReason(ex.Message));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return (false, TrimPendingReason($"入库应收计费失败:{ex.Message}"));
|
||
}
|
||
}
|
||
|
||
private static string TrimPendingReason(string? message)
|
||
{
|
||
var text = (message ?? string.Empty).Trim();
|
||
if (text.Length <= 512)
|
||
{
|
||
return text;
|
||
}
|
||
|
||
return text[..512];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 预报计费/排货异步任务(创单与草稿预报共用)。
|
||
/// </summary>
|
||
internal async Task ProcessForecastBillingForBatchAsync(
|
||
int orderId,
|
||
string? payloadJson = null,
|
||
int operatorUserId = 0,
|
||
CancellationToken cancellationToken = default,
|
||
string? authorization = null)
|
||
{
|
||
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
|
||
if (order == null)
|
||
{
|
||
// 创单事务尚未提交时消息可能先到。抛出让总线重试,避免当成成功 Ack 后订单永远停在处理中。
|
||
throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}");
|
||
}
|
||
|
||
if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 已预报成功则跳过(幂等)
|
||
if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase)
|
||
&& string.IsNullOrWhiteSpace(order.PredeclareFailReason))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
|
||
|
||
int? couponInstanceId = order.CouponInstanceId;
|
||
string? trackAuthorization = authorization;
|
||
if (!string.IsNullOrWhiteSpace(payloadJson))
|
||
{
|
||
try
|
||
{
|
||
var payload = JsonConvert.DeserializeObject<ForecastBillingJobPayload>(payloadJson);
|
||
if (payload?.CouponInstanceId is > 0)
|
||
{
|
||
couponInstanceId = payload.CouponInstanceId;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(trackAuthorization)
|
||
&& !string.IsNullOrWhiteSpace(payload?.Authorization))
|
||
{
|
||
trackAuthorization = payload.Authorization.Trim();
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore bad payload
|
||
}
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
||
{
|
||
await MarkParcelDraftFailedAsync(order, "订单未绑定销售产品,无法预报");
|
||
return;
|
||
}
|
||
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||
if (product == null || product.IsActive != EnableStatus.Enabled)
|
||
{
|
||
await MarkParcelDraftFailedAsync(order, "销售产品不存在或未启用");
|
||
return;
|
||
}
|
||
|
||
ProductSnapshotBinding snapshotBinding;
|
||
OrderBillingAddress address;
|
||
try
|
||
{
|
||
snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||
address = await ResolveOrderBillingAddressAsync(order);
|
||
order.IsRemote = await IsRemoteHitAsync(
|
||
product,
|
||
snapshotBinding.Rules,
|
||
address.CountryCode,
|
||
address.Province,
|
||
address.City,
|
||
address.ZipCode) ? (byte)1 : (byte)0;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await MarkParcelDraftFailedAsync(
|
||
order,
|
||
ex is BusinessException be ? be.Message : $"预报快照解析失败:{ex.Message}");
|
||
return;
|
||
}
|
||
|
||
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
||
.OrderBy(x => x.Id)
|
||
.ToList();
|
||
var forecast = weights.FirstOrDefault(x =>
|
||
string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase));
|
||
if (forecast == null)
|
||
{
|
||
await MarkParcelDraftFailedAsync(order, "订单缺少预报件重尺");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
|
||
SyncWeightRows(weights, forecast);
|
||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await MarkParcelDraftFailedAsync(
|
||
order,
|
||
ex is BusinessException be ? be.Message : $"计费重计算失败:{ex.Message}");
|
||
return;
|
||
}
|
||
|
||
OrderBillingContext billing;
|
||
try
|
||
{
|
||
billing = await ResolveOrderBillingContextAsync(
|
||
order,
|
||
product,
|
||
forecast.ChargeableWeight ?? 0,
|
||
snapshotBinding);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await MarkParcelDraftFailedAsync(
|
||
order,
|
||
ex is BusinessException be ? be.Message : $"计费上下文解析失败:{ex.Message}");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
await UpsertReceivableFeesAsync(
|
||
order,
|
||
billing,
|
||
OrderFeeChargingStageCodes.Forecast,
|
||
replaceExistingReceivable: true);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await MarkParcelDraftFailedAsync(
|
||
order,
|
||
ex is BusinessException be ? be.Message : $"预报应收计费失败:{ex.Message}");
|
||
return;
|
||
}
|
||
|
||
// 排货失败不阻断预报成功(与原创单同步路径一致)
|
||
var allocated = await TryConfirmServiceChannelAndPayableAsync(
|
||
order,
|
||
product,
|
||
billing,
|
||
OrderFeeChargingStageCodes.Forecast);
|
||
|
||
try
|
||
{
|
||
await ApplyMarketingSnapshotOnCreateAsync(order, couponInstanceId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||
.CreateLogger("Oms.ForecastBilling");
|
||
logger.LogWarning(ex, "预报营销快照失败 OrderId={OrderId}", order.Id);
|
||
}
|
||
|
||
try
|
||
{
|
||
await CaptureForecastBillingSnapshotAsync(order);
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
|
||
order.OrderStatus = OrderStatusCodes.Predeclared;
|
||
order.PredeclareFailReason = string.Empty;
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
// 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。
|
||
// 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。
|
||
if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode))
|
||
{
|
||
await PublishForecastTrackAsync(order.Id, trackAuthorization);
|
||
}
|
||
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.PreDeclare,
|
||
beforeSnap,
|
||
afterSnap,
|
||
"预报计费/排货完成",
|
||
operatorUserId > 0 ? operatorUserId : null,
|
||
operatorName: null,
|
||
operatorType: "staff",
|
||
source: "job");
|
||
}
|
||
|
||
private sealed class ForecastBillingJobPayload
|
||
{
|
||
public int? CouponInstanceId { get; set; }
|
||
|
||
/// <summary>
|
||
/// 创单/预报请求的 Authorization,供排货后异步创建轨迹订单转发到轨迹网关。
|
||
/// </summary>
|
||
public string? Authorization { get; set; }
|
||
}
|
||
|
||
private async Task PublishForecastTrackAsync(int orderId, string? authorization = null)
|
||
{
|
||
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId);
|
||
if (order == null
|
||
|| string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)
|
||
|| string.IsNullOrWhiteSpace(order.ChannelCode))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var customer = order.CustomerId.HasValue
|
||
? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
|
||
: null;
|
||
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null);
|
||
request.AppendInitialTrackOnSuccess = true;
|
||
ApplyTrackAuthorization(request, authorization);
|
||
await EventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
|
||
{
|
||
OrderId = order.Id,
|
||
CustomerId = customer?.Id,
|
||
PayloadJson = JsonConvert.SerializeObject(request),
|
||
AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
|
||
Authorization = request.Authorization
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 预报计费入队。队列开着写 ForecastBilling Outbox;关着则在当前事务里同步跑同一套计费。
|
||
/// Authorization 放进任务载荷,供排货成功后转发轨迹网关。
|
||
/// </summary>
|
||
private async Task EnqueueForecastBillingAsync(
|
||
Order order,
|
||
int? couponInstanceId,
|
||
int operatorUserId,
|
||
string? authorization = null)
|
||
{
|
||
authorization ??= CaptureRequestAuthorization();
|
||
var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload
|
||
{
|
||
CouponInstanceId = couponInstanceId,
|
||
Authorization = authorization
|
||
});
|
||
|
||
if (!BatchQueueEnabled)
|
||
{
|
||
await ProcessForecastBillingForBatchAsync(
|
||
order.Id,
|
||
payloadJson,
|
||
operatorUserId,
|
||
authorization: authorization);
|
||
return;
|
||
}
|
||
|
||
var requestId = $"forecast-billing:{order.Id}:{Guid.NewGuid():N}";
|
||
await BatchJobCoordinator.CreateAndEnqueueAsync(
|
||
new OrderBatchJobCreateInput
|
||
{
|
||
Action = OrderBatchActionCodes.ForecastBilling,
|
||
OrderIds = [order.Id],
|
||
RequestId = requestId,
|
||
PayloadJson = payloadJson
|
||
},
|
||
operatorUserId,
|
||
authorization);
|
||
}
|
||
|
||
private async Task EnqueueInboundBillingAsync(Order order)
|
||
{
|
||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||
.CreateLogger("Oms.InboundBilling");
|
||
try
|
||
{
|
||
if (!BatchQueueEnabled)
|
||
{
|
||
// 本地未开队列:同步跑异步计费逻辑,仍不向外抛(入库主流程已完成)
|
||
try
|
||
{
|
||
await ProcessInboundBillingForBatchAsync(order.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogError(ex, "入库计费同步执行失败 OrderId={OrderId} ServiceNo={ServiceNo}", order.Id, order.ServiceNo);
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
order.AllocationPendingReason = TrimPendingReason($"入库计费失败:{ex.Message}");
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
var requestId = await ResolveInboundBillingRequestIdAsync(order.Id);
|
||
if (requestId == null)
|
||
{
|
||
logger.LogInformation(
|
||
"入库计费任务已在队列中,跳过重复投递 OrderId={OrderId} ServiceNo={ServiceNo}",
|
||
order.Id,
|
||
order.ServiceNo);
|
||
return;
|
||
}
|
||
|
||
await BatchJobCoordinator.CreateAndEnqueueAsync(
|
||
new OrderBatchJobCreateInput
|
||
{
|
||
Action = OrderBatchActionCodes.InboundBilling,
|
||
OrderIds = [order.Id],
|
||
RequestId = requestId
|
||
},
|
||
CurrentUser.IsAuthenticated ? CurrentUser.GetId<int>() : 0);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// 投递失败不回滚入库;落待排货并记错误日志,供运营重跑入库计费
|
||
logger.LogError(
|
||
ex,
|
||
"入库计费任务投递失败 OrderId={OrderId} ServiceNo={ServiceNo}",
|
||
order.Id,
|
||
order.ServiceNo);
|
||
try
|
||
{
|
||
order.OrderStatus = OrderStatusCodes.PendingAllocation;
|
||
if (string.IsNullOrWhiteSpace(order.AllocationPendingReason)
|
||
|| order.AllocationPendingReason == InboundBillingProcessingReason)
|
||
{
|
||
order.AllocationPendingReason = InboundBillingEnqueueFailReason;
|
||
}
|
||
|
||
order.UpdateTime = DateTime.Now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
}
|
||
catch
|
||
{
|
||
// ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 幂等 RequestId:进行中任务复用并跳过;已结束任务换新 ID 允许重跑。
|
||
/// 返回 null 表示已有进行中任务,无需再投递。
|
||
/// </summary>
|
||
private async Task<string?> ResolveInboundBillingRequestIdAsync(int orderId)
|
||
{
|
||
var batchJobRepository = LazyServiceProvider.GetRequiredService<IOrderBatchJobRepository>();
|
||
var stableId = $"inbound-billing:{orderId}";
|
||
var existing = await batchJobRepository.GetByRequestIdAsync(stableId);
|
||
if (existing != null)
|
||
{
|
||
if (IsActiveBatchJob(existing.Status))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
// 旧稳定 ID 已结束,后续用带时间戳 ID,避免被已完成任务挡住重跑
|
||
return $"inbound-billing:{orderId}:{DateTime.UtcNow:yyyyMMddHHmmssfff}";
|
||
}
|
||
|
||
return stableId;
|
||
}
|
||
|
||
private static bool IsActiveBatchJob(string? status) =>
|
||
string.Equals(status, OrderBatchJobStatusCodes.Pending, StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(status, OrderBatchJobStatusCodes.Running, StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// 出库发货后:仅覆盖 ship 件重尺、重算计费重并更新状态;不再重算应收。
|
||
/// </summary>
|
||
internal async Task ApplyShipBillingByWaybillAsync(
|
||
string waybillNo,
|
||
decimal? length,
|
||
decimal? width,
|
||
decimal? height,
|
||
decimal? actualWeight)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(waybillNo))
|
||
{
|
||
throw new BusinessException("运单号不能为空");
|
||
}
|
||
|
||
ParcelBillingValidators.EnsurePositiveDimensions(length, width, height, actualWeight, "发货");
|
||
|
||
var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim());
|
||
if (order == null)
|
||
{
|
||
throw new BusinessException("未找到对应订单,请检查单号");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(order.ProductCode))
|
||
{
|
||
throw new BusinessException("订单未绑定销售产品,无法出库计费");
|
||
}
|
||
|
||
if (order.IsPaid == 0)
|
||
{
|
||
throw new BusinessException("订单未付款,禁止出库");
|
||
}
|
||
|
||
if (order.OrderStatus != OrderStatusCodes.PendingShip)
|
||
{
|
||
if (order.OrderStatus is OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping
|
||
or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed)
|
||
{
|
||
throw new BusinessException("订单已出库");
|
||
}
|
||
|
||
throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许出库");
|
||
}
|
||
|
||
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
|
||
if (product == null)
|
||
{
|
||
throw new BusinessException("订单关联的销售产品不存在");
|
||
}
|
||
|
||
EnsureBillingProductActive(product);
|
||
|
||
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
|
||
var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
|
||
.OrderBy(x => x.Id)
|
||
.ToList();
|
||
var ship = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Ship);
|
||
if (ship == null)
|
||
{
|
||
throw new BusinessException("订单缺少发货件重尺记录");
|
||
}
|
||
|
||
ApplyMeasuredDimensions(ship, length!.Value, width!.Value, height!.Value, actualWeight!.Value);
|
||
|
||
var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
|
||
await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, ship);
|
||
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
|
||
|
||
// 出库仅更新件重尺与状态,不再重算/覆盖应收。
|
||
var now = DateTime.Now;
|
||
order.OrderStatus = OrderStatusCodes.PendingOnline;
|
||
order.ShipTime = now;
|
||
order.UpdateTime = now;
|
||
await OrderRepository.UpdateAsync(order);
|
||
|
||
order = await GetOrderOrThrowAsync(order.Id);
|
||
var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeWeights: true);
|
||
await OperationLogWriter.TryWriteAsync(
|
||
order,
|
||
OrderOperationActionCodes.Outbound,
|
||
beforeSnap,
|
||
afterSnap,
|
||
FormatMeasuredDimensionsRemark("发货", length!.Value, width!.Value, height!.Value, actualWeight!.Value));
|
||
|
||
await EnqueueLifecycleTrackAppendAsync(order, SmallBagTrackLifecycleEvents.OutboundTrackCode);
|
||
}
|
||
|
||
private static string FormatMeasuredDimensionsRemark(
|
||
string stage,
|
||
decimal length,
|
||
decimal width,
|
||
decimal height,
|
||
decimal actualWeight)
|
||
{
|
||
return $"{stage} 长×宽×高×重:{length:0.####}×{width:0.####}×{height:0.####}×{actualWeight:0.####}";
|
||
}
|
||
|
||
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
|
||
{
|
||
// 预报已成功时,映射/面单/本地存储失败不得回滚订单(常见:容器 /app/wwwroot 不可写)。
|
||
var logger = LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||
.CreateLogger("Oms.OrderParcelCreate");
|
||
var labelUrl = string.Empty;
|
||
try
|
||
{
|
||
var mapped = (await MapOrderOutputsAsync([order])).First();
|
||
try
|
||
{
|
||
labelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id) ?? string.Empty;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(
|
||
ex,
|
||
"预报成功但读取系统面单 URL 失败 OrderId={OrderId} ServiceNo={ServiceNo}",
|
||
order.Id,
|
||
order.ServiceNo);
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared)
|
||
{
|
||
try
|
||
{
|
||
labelUrl = await CreateOrderPrintFileAsync(mapped);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(
|
||
ex,
|
||
"预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}",
|
||
order.Id,
|
||
order.ServiceNo);
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(
|
||
ex,
|
||
"预报成功但组装创单出参失败 OrderId={OrderId} ServiceNo={ServiceNo}",
|
||
order.Id,
|
||
order.ServiceNo);
|
||
}
|
||
|
||
return new OrderParcelCreateOutput
|
||
{
|
||
OrderId = order.Id,
|
||
ClientNo = order.ClientNo,
|
||
ServiceNo = order.ServiceNo,
|
||
TrackNo = order.TrackNo,
|
||
ConsigneeCountry = order.ConsigneeCountry,
|
||
ConsigneeCountryName = order.ConsigneeCountryName,
|
||
LabelUrl = labelUrl,
|
||
OrderStatus = order.OrderStatus,
|
||
PredeclareFailReason = order.PredeclareFailReason,
|
||
ForecastBillingQueued = string.Equals(
|
||
order.PredeclareFailReason,
|
||
ForecastBillingProcessingReason,
|
||
StringComparison.Ordinal)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。
|
||
/// </summary>
|
||
private async Task EnqueueSmallBagTrackUpsertAsync(
|
||
Order order,
|
||
Customer? customer,
|
||
OrderParcelReceiverInput? receiver,
|
||
bool appendInitialTrack = false)
|
||
{
|
||
var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver);
|
||
request.AppendInitialTrackOnSuccess = appendInitialTrack;
|
||
ApplyTrackAuthorization(request);
|
||
await EventBus.PublishAsync(new OrderParcelTrackUpsertIntegrationEvent
|
||
{
|
||
OrderId = order.Id,
|
||
CustomerId = customer?.Id,
|
||
PayloadJson = JsonConvert.SerializeObject(request),
|
||
AppendInitialTrackOnSuccess = request.AppendInitialTrackOnSuccess,
|
||
Authorization = request.Authorization
|
||
});
|
||
}
|
||
|
||
private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(authorization))
|
||
{
|
||
request.Authorization = authorization.Trim();
|
||
return;
|
||
}
|
||
|
||
var fromRequest = CaptureRequestAuthorization();
|
||
if (!string.IsNullOrWhiteSpace(fromRequest))
|
||
{
|
||
request.Authorization = fromRequest;
|
||
}
|
||
}
|
||
|
||
private string? CaptureRequestAuthorization()
|
||
{
|
||
try
|
||
{
|
||
var authorization = LazyServiceProvider
|
||
.GetRequiredService<IHttpContextAccessor>()
|
||
.HttpContext?
|
||
.Request
|
||
.Headers
|
||
.Authorization
|
||
.ToString();
|
||
return string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim();
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 跟踪号相对旧值有变更且新值非空时,再创建/更新一次轨迹订单。
|
||
/// </summary>
|
||
private async Task EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(
|
||
Order order,
|
||
string? previousTrackNo,
|
||
bool appendInitialTrack = true)
|
||
{
|
||
var next = order.TrackNo?.Trim() ?? string.Empty;
|
||
var prev = previousTrackNo?.Trim() ?? string.Empty;
|
||
if (string.IsNullOrWhiteSpace(next)
|
||
|| string.Equals(next, prev, StringComparison.Ordinal))
|
||
{
|
||
return;
|
||
}
|
||
|
||
await EnqueueSmallBagTrackUpsertAsync(order, customer: null, receiver: null, appendInitialTrack);
|
||
}
|
||
|
||
private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(order.ServiceNo))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var authorization = LazyServiceProvider
|
||
.GetRequiredService<IHttpContextAccessor>()
|
||
.HttpContext?
|
||
.Request
|
||
.Headers
|
||
.Authorization
|
||
.ToString();
|
||
|
||
await EventBus.PublishAsync(new OrderLifecycleTrackAppendIntegrationEvent
|
||
{
|
||
OrderId = order.Id,
|
||
OrderNo = order.ServiceNo,
|
||
TrackCode = trackCode,
|
||
Authorization = string.IsNullOrWhiteSpace(authorization) ? null : authorization
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按订单同步轨迹(UpsertSmallBag),返回完整请求与响应便于联调。
|
||
/// </summary>
|
||
[HttpPost("/order/track-upsert-small-bag")]
|
||
public async Task<SmallBagTrackUpsertResult> TrackUpsertSmallBagAsync(OrderActionInput input)
|
||
{
|
||
var order = await GetOrderOrThrowAsync(input.Id);
|
||
if (string.IsNullOrWhiteSpace(order.ServiceNo))
|
||
{
|
||
throw new BusinessException("订单运单号为空,无法同步轨迹");
|
||
}
|
||
|
||
var request = await BuildSmallBagTrackUpsertRequestAsync(order);
|
||
request.AppendInitialTrackOnSuccess = true;
|
||
var authorization = LazyServiceProvider
|
||
.GetRequiredService<IHttpContextAccessor>()
|
||
.HttpContext?
|
||
.Request
|
||
.Headers
|
||
.Authorization
|
||
.ToString();
|
||
if (!string.IsNullOrWhiteSpace(authorization))
|
||
{
|
||
request.Authorization = authorization;
|
||
}
|
||
|
||
return await SmallBagTrackOrderClient.UpsertDebugAsync(request);
|
||
}
|
||
|
||
private async Task<SmallBagTrackUpsertRequest> BuildSmallBagTrackUpsertRequestAsync(
|
||
Order order,
|
||
OrderParcelReceiverInput? receiverInput = null)
|
||
{
|
||
return await SmallBagTrackUpsertRequestBuilder.BuildAsync(order, receiverInput);
|
||
}
|
||
|
||
private ISmallBagTrackUpsertRequestBuilder SmallBagTrackUpsertRequestBuilder =>
|
||
LazyServiceProvider.GetRequiredService<ISmallBagTrackUpsertRequestBuilder>();
|
||
|
||
private ISmallBagTrackOrderClient SmallBagTrackOrderClient =>
|
||
LazyServiceProvider.GetRequiredService<ISmallBagTrackOrderClient>();
|
||
|
||
/// <summary>
|
||
/// 调试:同步调用轨迹 UpsertSmallBag,返回完整请求与响应(Swagger 可测)。
|
||
/// </summary>
|
||
[HttpPost("/order/debug/track-upsert-small-bag")]
|
||
public Task<SmallBagTrackUpsertResult> DebugTrackUpsertSmallBagAsync([FromBody] OrderTrackUpsertDebugInput? input)
|
||
{
|
||
input ??= new OrderTrackUpsertDebugInput();
|
||
var suffix = DateTime.Now.ToString("yyyyMMddHHmmss");
|
||
var request = new SmallBagTrackUpsertRequest
|
||
{
|
||
Client = string.IsNullOrWhiteSpace(input.Client) ? "TEST001" : input.Client.Trim(),
|
||
OrderNo = string.IsNullOrWhiteSpace(input.OrderNo) ? $"LP-DEBUG-{suffix}" : input.OrderNo.Trim(),
|
||
TrackNo = input.TrackNo?.Trim() ?? string.Empty,
|
||
OtherNo = string.IsNullOrWhiteSpace(input.OtherNo) ? $"DEBUG-{suffix}" : input.OtherNo.Trim(),
|
||
Channel = string.IsNullOrWhiteSpace(input.Channel) ? "test01" : input.Channel.Trim(),
|
||
Product = string.IsNullOrWhiteSpace(input.Product) ? "test01" : input.Product.Trim(),
|
||
ApiCode = input.ApiCode?.Trim() ?? string.Empty,
|
||
Consignee = new SmallBagTrackConsigneeRequest
|
||
{
|
||
CountryCode = string.IsNullOrWhiteSpace(input.CountryCode) ? "US" : input.CountryCode.Trim(),
|
||
Province = input.Province?.Trim() ?? "California",
|
||
Postcode = input.Postcode?.Trim() ?? "90001",
|
||
City = input.City?.Trim() ?? "Los Angeles",
|
||
Email = input.Email?.Trim() ?? "debug@example.com"
|
||
}
|
||
};
|
||
|
||
return SmallBagTrackOrderClient.UpsertDebugAsync(request);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。
|
||
/// </summary>
|
||
private Task<Customer> ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) =>
|
||
OmsSessionHelper.ResolveSessionCustomerAsync(
|
||
CustomerRepository,
|
||
CurrentUser,
|
||
requestedCustomerId);
|
||
|
||
private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
|
||
{
|
||
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||
var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
|
||
var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
|
||
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
|
||
var breakdown = await marketingHelper.BuildBreakdownAsync(
|
||
order.CustomerId,
|
||
receivableSubtotal,
|
||
baseFreightAmount,
|
||
couponInstanceId,
|
||
order.ConsigneeCountry);
|
||
marketingHelper.ApplySnapshotToOrder(order, breakdown, couponInstanceId);
|
||
|
||
if (breakdown.CouponInstanceId.HasValue && order.CustomerId.HasValue)
|
||
{
|
||
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(order.CustomerId.Value);
|
||
await couponManager.LockForOrderAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 预报计费完成后固化应收/应付快照;入库重算不得覆盖。
|
||
/// </summary>
|
||
private async Task CaptureForecastBillingSnapshotAsync(Order order)
|
||
{
|
||
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||
order.ForecastReceivableAmount = order.ReceivableSubtotal
|
||
?? fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount);
|
||
order.ForecastPayableAmount = fees
|
||
.Where(x => x.Type == OrderFeeTypeCodes.Payable)
|
||
.Sum(x => x.Amount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 入库等场景重算应收后,按当前应收小计刷新营销应付金额(不重新锁定优惠券)。
|
||
/// </summary>
|
||
private async Task RefreshMarketingPayableAmountAsync(Order order)
|
||
{
|
||
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
|
||
var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
|
||
var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
|
||
var marketingHelper = LazyServiceProvider.GetRequiredService<MarketingOrderHelper>();
|
||
var breakdown = await marketingHelper.BuildBreakdownAsync(
|
||
order.CustomerId,
|
||
receivableSubtotal,
|
||
baseFreightAmount,
|
||
order.CouponInstanceId,
|
||
order.ConsigneeCountry,
|
||
order.Id);
|
||
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
|
||
}
|
||
|
||
private static void EnsureReceivableMutable(Order order)
|
||
{
|
||
if (order.IsPaid != 0)
|
||
{
|
||
throw new BusinessException("订单已付款,不能修改应收费用");
|
||
}
|
||
}
|
||
|
||
private static void EnsureAdditionalServicesAllowed(SalesProduct product, List<string>? selected)
|
||
{
|
||
var selectedCodes = NormalizeAdditionalServiceCodes(selected);
|
||
if (selectedCodes.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var allowed = ParseAdditionalServiceCodes(product.AdditionalServices);
|
||
if (allowed.Count == 0)
|
||
{
|
||
throw new BusinessException("销售产品未配置附加服务,不能勾选附加服务");
|
||
}
|
||
|
||
var invalid = selectedCodes
|
||
.Where(code => !allowed.Contains(code))
|
||
.ToList();
|
||
if (invalid.Count > 0)
|
||
{
|
||
throw new BusinessException($"附加服务不在产品可选范围:{string.Join(", ", invalid)}");
|
||
}
|
||
}
|
||
|
||
private static string? SerializeAdditionalServices(List<string>? codes)
|
||
{
|
||
var normalized = NormalizeAdditionalServiceCodes(codes);
|
||
return normalized.Count == 0 ? null : OmsDocHelper.SerializeJson(normalized);
|
||
}
|
||
|
||
private static List<string> NormalizeAdditionalServiceCodes(IEnumerable<string>? codes) =>
|
||
(codes ?? [])
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.Select(x => x.Trim())
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
private static HashSet<string> ParseAdditionalServiceCodes(string? json)
|
||
{
|
||
var codes = OmsDocHelper.DeserializeJson<List<string>>(json) ?? [];
|
||
return NormalizeAdditionalServiceCodes(codes)
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static OrderRuleEvaluationContext BuildBillingConditionContext(
|
||
Order order,
|
||
byte? overrideIsRemote = null)
|
||
{
|
||
var evalOrder = overrideIsRemote.HasValue
|
||
? new Order
|
||
{
|
||
Id = order.Id,
|
||
ProductCode = order.ProductCode,
|
||
ChannelCode = order.ChannelCode,
|
||
ConsigneeCountry = order.ConsigneeCountry,
|
||
ConsigneeCountryName = order.ConsigneeCountryName,
|
||
CargoAttribute = order.CargoAttribute,
|
||
WarehouseId = order.WarehouseId,
|
||
CustomerId = order.CustomerId,
|
||
DeclaredCurrency = order.DeclaredCurrency,
|
||
DeclaredSum = order.DeclaredSum,
|
||
Quantity = order.Quantity,
|
||
IsRemote = overrideIsRemote.Value,
|
||
AdditionalServices = order.AdditionalServices,
|
||
IsPaid = order.IsPaid,
|
||
IsCod = order.IsCod,
|
||
CodCurrency = order.CodCurrency,
|
||
CodSum = order.CodSum
|
||
}
|
||
: order;
|
||
|
||
return OrderRuleEvaluationContext.FromOrderOnly(evalOrder);
|
||
}
|
||
|
||
}
|