admin/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs

694 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Fake.Application;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Orders;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.Shared.Enums;
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 ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
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 async Task<OrderParcelCreateOutput> CreateParcelCoreAsync(OrderParcelCreateInput input)
{
if (!string.IsNullOrWhiteSpace(input.RequestId))
{
var existed = await OrderRepository.GetByRequestIdAsync(input.RequestId);
if (existed != null)
{
return await BuildParcelCreateOutputAsync(existed);
}
}
var bindTime = DateTime.Now;
await ValidateParcelCreateInputAsync(input);
var product = await SalesProductRepository.GetByProductCodeAsync(input.ProductCode!.Trim());
if (product == null || product.IsActive != EnableStatus.Enabled)
{
throw new BusinessException("销售产品不存在或未启用");
}
var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime);
var country = await CountryRepository.FirstOrDefaultAsync(x => x.Id == input.Receiver!.CountryCode);
if (country == null)
{
throw new BusinessException("收件国家不存在");
}
if (await IsBlacklistHitAsync(
product,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode))
{
throw new BusinessException("收件地址命中黑名单区域");
}
var serviceNo = await ResolveServiceNoAsync(input.ServiceNo);
var order = BuildDraftOrderEntity(input, product, country.CountryCode, snapshotBinding, serviceNo);
order = await OrderRepository.InsertAsync(order);
var weights = BuildInitialWeightRows(order.Id, input);
await SaveParcelChildrenAsync(order, input, weights);
try
{
order.IsRemote = await IsRemoteHitAsync(
product,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode) ? (byte)1 : (byte)0;
var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast);
await ApplyChargeableWeightAsync(product, forecast);
SyncWeightRows(weights, forecast);
await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
var zoneName = await MatchPriceZoneAsync(
product,
country.CountryCode,
input.Receiver?.Province,
input.Receiver?.City,
input.Receiver?.ZipCode);
if (string.IsNullOrWhiteSpace(zoneName))
{
throw new BusinessException("价格分区未匹配(规则快照)");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
var fees = ParcelBillingCalculator.CalculateForecastReceivableFees(
snapshotBinding.Price,
snapshotBinding.PriceVersion,
snapshotBinding.PriceEffectiveTime,
forecast.ChargeableWeight ?? 0,
zoneName,
order.CargoAttribute ?? CargoAttributeCodes.General,
customer?.CustomerGroup);
if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledForecastPriceItems(snapshotBinding.Price))
{
throw new BusinessException("产品卖价快照未配置或未匹配到有效价格");
}
if (fees.Count > 0)
{
await OrderRepository.UpsertFeesAsync(order.Id, fees);
}
order.OrderStatus = OrderStatusCodes.Predeclared;
order.PredeclareFailReason = string.Empty;
order.IsRemote = order.IsRemote;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
return await BuildParcelCreateOutputAsync(order);
}
catch (BusinessException ex)
{
await MarkParcelDraftFailedAsync(order, ex.Message);
throw;
}
catch (Exception ex)
{
await MarkParcelDraftFailedAsync(order, ex.Message);
throw new BusinessException(ex.Message);
}
}
private async Task ValidateParcelCreateInputAsync(OrderParcelCreateInput input)
{
if (string.IsNullOrWhiteSpace(input.ProductCode))
{
throw new BusinessException("产品代码不能为空");
}
if (!input.WarehouseId.HasValue || input.WarehouseId <= 0)
{
throw new BusinessException("仓库ID不能为空");
}
if (!input.CustomerId.HasValue || input.CustomerId <= 0)
{
throw new BusinessException("客户ID不能为空");
}
if (!input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue || !input.ActualWeight.HasValue
|| input.Length <= 0 || input.Width <= 0 || input.Height <= 0 || 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("出口报关方式不合法");
}
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)
|| !input.Receiver.CountryCode.HasValue
|| 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("客户不存在或状态不可用");
}
}
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.DeserializeJson<SalesProductRulesPublishSnapshotDto>(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.DeserializeJson<SalesProductPricePublishSnapshotDto>(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)");
}
return 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
};
}
private async Task<SalesProductRulesPublishSnapshotDto> BuildLiveRulesSnapshotAsync(SalesProduct product)
{
return 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
}
};
}
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());
return new SalesProductPricePublishSnapshotDto
{
SalesProductId = product.Id,
Prices = prices.Select(price => 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,
ChargingTime = price.ChargingTime,
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()
};
}
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 async Task<bool> IsBlacklistHitAsync(
SalesProduct product,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!product.BlacklistAreaId.HasValue || product.BlacklistAreaId <= 0)
{
return false;
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.BlacklistAreaId.Value);
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
countryCode,
province,
city,
zipCode));
}
private async Task<bool> IsRemoteHitAsync(
SalesProduct product,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!product.RemoteAreaId.HasValue || product.RemoteAreaId <= 0)
{
return false;
}
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.RemoteAreaId.Value);
return rules.Any(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
countryCode,
province,
city,
zipCode));
}
private async Task<string?> MatchPriceZoneAsync(
SalesProduct product,
string countryCode,
string? province,
string? city,
string? zipCode)
{
if (!product.PriceAreaId.HasValue || product.PriceAreaId <= 0)
{
return "默认分区";
}
var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == product.PriceAreaId.Value);
var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(product.PriceAreaId.Value);
var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched(
rule,
countryCode,
province,
city,
zipCode));
return ParcelBillingCalculator.ResolveZoneName(matched, area);
}
private async Task ApplyChargeableWeightAsync(SalesProduct product, OrderWeight forecast)
{
try
{
if (!product.WeightRuleId.HasValue || product.WeightRuleId <= 0)
{
throw new BusinessException("未配置计重规则");
}
var weightRule = await WeightRuleRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRuleId.Value);
if (weightRule == null)
{
throw new BusinessException("未配置计重规则");
}
var ruleInfos = await WeightRuleRepository.GetAllInfosByRuleIdAsync(product.WeightRuleId.Value);
var (volumetricWeight, rawWeight) = ParcelBillingCalculator.CalculateRawBillableWeight(forecast, ruleInfos);
forecast.VolumetricWeight = volumetricWeight;
if (!product.WeightRoundingId.HasValue || product.WeightRoundingId <= 0)
{
throw new BusinessException("未配置计重进位");
}
var rounding = await WeightRoundingRepository.FirstOrDefaultAsync(x => x.Id == product.WeightRoundingId.Value);
if (rounding == null)
{
throw new BusinessException("未配置计重进位");
}
var ranges = await WeightRoundingRepository.GetAllInfosByRoundingIdAsync(product.WeightRoundingId.Value);
forecast.ChargeableWeight = ParcelBillingCalculator.ApplyRounding(rawWeight, ranges);
}
catch (BusinessException)
{
throw;
}
catch (InvalidOperationException ex)
{
throw new BusinessException(ex.Message);
}
}
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,
ProductSnapshotBinding snapshotBinding,
string serviceNo)
{
return new Order
{
RequestId = input.RequestId,
ClientNo = input.ClientNo,
TrackNo = input.TrackNo,
ServiceNo = serviceNo,
ProductCode = input.ProductCode,
ChannelCode = input.ChannelCode ?? product.ProductCode,
ConsigneeCountry = consigneeCountry,
CargoAttribute = input.CargoAttribute ?? CargoAttributeCodes.General,
ExportCustomsMode = input.ExportCustomsMode,
WarehouseId = input.WarehouseId,
CustomerId = input.CustomerId,
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,
PredeclareFailReason = string.Empty,
SalesProductRulesVersion = snapshotBinding.RulesVersion,
SalesProductPriceVersion = snapshotBinding.PriceVersion,
OrderStatus = OrderStatusCodes.Draft
};
}
private async Task SaveParcelChildrenAsync(Order order, OrderParcelCreateInput input, List<OrderWeight> weights)
{
if (input.Sender != null)
{
var sender = await OrderRepository.InsertSenderAsync(new OrderSender
{
OrderId = order.Id,
SenderName = input.Sender.SenderName,
Company = input.Sender.Company,
Country = input.Sender.CountryCode,
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 = input.Receiver.CountryCode,
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,
SkuImage = x.SkuImage,
Category = x.Category,
Brand = x.Brand,
BrandType = x.BrandType,
IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)0,
Distribution = x.Distribution,
Material = x.Material,
EnMaterial = x.EnMaterial,
Purpose = x.Purpose,
Attributes = x.Attributes,
OriginCountry = x.OriginCountry,
SalesLink = x.SalesLink,
Model = x.Model,
Specifications = x.Specifications,
Comment = x.Comment
}));
}
await OrderRepository.InsertWeightsAsync(weights);
await OrderRepository.UpdateAsync(order);
}
private async Task<string> ResolveServiceNoAsync(string? serviceNo)
{
var normalized = serviceNo?.Trim();
if (!string.IsNullOrWhiteSpace(normalized))
{
if (await OrderRepository.HasServiceNoAsync(normalized))
{
throw new BusinessException("运单号已存在");
}
return normalized;
}
for (var i = 0; i < 5; i++)
{
var generated = $"LP{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}";
if (!await OrderRepository.HasServiceNoAsync(generated))
{
return generated;
}
}
throw new BusinessException("运单号生成失败");
}
private async Task MarkParcelDraftFailedAsync(Order order, string reason)
{
order.OrderStatus = OrderStatusCodes.Draft;
order.PredeclareFailReason = reason;
order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order);
}
private async Task<OrderParcelCreateOutput> BuildParcelCreateOutputAsync(Order order)
{
var mapped = (await MapOrderOutputsAsync([order])).First();
return new OrderParcelCreateOutput
{
ClientNo = order.ClientNo,
ServiceNo = order.ServiceNo,
TrackNo = order.TrackNo,
LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared
? await CreateOrderPrintFileAsync(mapped)
: string.Empty
};
}
}