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.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 ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); private IWeightRuleRepository WeightRuleRepository => LazyServiceProvider.GetRequiredService(); private IWeightRoundingRepository WeightRoundingRepository => LazyServiceProvider.GetRequiredService(); private IKpiAreaRepository KpiAreaRepository => LazyServiceProvider.GetRequiredService(); private async Task 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; NormalizeParcelCreateInput(input); 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("销售产品不存在或未启用"); } var snapshotBinding = await ResolveSnapshotBindingAsync(product, bindTime); var country = await GetCountryOrThrowAsync(input.Receiver!.CountryCode); 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, country); 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 zoneKeys = await ResolvePriceZoneKeysAsync( product, snapshotBinding.Rules, snapshotBinding.Price, country.CountryCode, input.Receiver?.Province, input.Receiver?.City, input.Receiver?.ZipCode); if (zoneKeys == null || zoneKeys.Count == 0) { 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, zoneKeys, 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 static void NormalizeParcelCreateInput(OrderParcelCreateInput input) { input.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim(); input.ServiceNo = string.IsNullOrWhiteSpace(input.ServiceNo) ? null : input.ServiceNo.Trim(); input.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim(); input.RequestId = string.IsNullOrWhiteSpace(input.RequestId) ? null : input.RequestId.Trim(); } 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) || 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("客户不存在或状态不可用"); } } private async Task 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(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(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 Task BuildLiveRulesSnapshotAsync(SalesProduct product) { return Task.FromResult(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 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 => 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() }; await PriceAreaSnapshotHelper.ApplyPriceAreaToSnapshotAsync(snapshot, product, KpiAreaRepository); 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>(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 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 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 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 async Task?> ResolvePriceZoneKeysAsync( SalesProduct product, SalesProductRulesPublishSnapshotDto? rulesSnapshot, SalesProductPricePublishSnapshotDto? priceSnapshot, string countryCode, string? province, string? city, string? zipCode) { if (priceSnapshot?.PriceAreaInfos is { Count: > 0 }) { return ParcelBillingCalculator.TryResolveZoneKeysFromPriceSnapshot( priceSnapshot, countryCode, province, city, zipCode); } var priceAreaId = ResolveEffectivePriceAreaId(product, rulesSnapshot, priceSnapshot); if (!priceAreaId.HasValue) { return null; } var area = await KpiAreaRepository.FirstOrDefaultAsync(x => x.Id == priceAreaId.Value); if (area == null) { return null; } var rules = await KpiAreaRepository.GetAllInfosByAreaIdAsync(priceAreaId.Value); var matched = rules.FirstOrDefault(rule => ParcelBillingCalculator.IsAreaMatched( rule, countryCode, province, city, zipCode)); if (matched == null) { return null; } var keys = ParcelBillingCalculator.BuildPriceZoneKeys(matched, area); return keys.Count > 0 ? keys : null; } 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 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 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 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, 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 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 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 }; } /// /// 从会话解析有效客户(客户登录自动补全;管理员须传 customerId)。 /// private Task ResolveEffectiveCustomerAsync(int? requestedCustomerId = null) => OmsSessionHelper.ResolveSessionCustomerAsync( CustomerRepository, CurrentUser, requestedCustomerId); }