diff --git a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs index e66c573..e1a76b4 100644 --- a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs +++ b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs @@ -9,11 +9,10 @@ namespace Oms.Application.Dtos.Customers; public class CreateCustomerInput { /// - /// 客户编号。 + /// 客户编号(留空时系统自动生成,规则 W+年份后2位+月份2位+3位顺序)。 /// - [Required] [StringLength(32)] - public string CustomerCode { get; set; } = string.Empty; + public string? CustomerCode { get; set; } /// /// 客户名称。 @@ -32,9 +31,10 @@ public class CreateCustomerInput /// /// 邮箱。 /// + [Required] [EmailAddress] [StringLength(64)] - public string? Email { get; set; } + public string Email { get; set; } = string.Empty; /// /// 微信头像地址。 diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckFailureOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckFailureOutput.cs index e7108c9..72571f0 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckFailureOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckFailureOutput.cs @@ -29,4 +29,14 @@ public class OrderPriceCheckFailureOutput /// 未命中原因说明。 /// public string Reason { get; set; } = string.Empty; + + /// + /// 货物类型名称(展示)。 + /// + public string? CargoTypeName { get; set; } + + /// + /// 仓库名称(展示)。 + /// + public string? WarehouseName { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs index 3cfacda..1b74736 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckInput.cs @@ -36,10 +36,20 @@ public class OrderPriceCheckInput public decimal? Height { get; set; } /// - /// 货物类型。 + /// 货物类型(单选,兼容旧版;请优先使用 CargoTypes)。 /// public string? CargoType { get; set; } + /// + /// 货物类型(多选,字典 cargo_attribute 编码)。 + /// + public List? CargoTypes { get; set; } + + /// + /// 仓库主键(多选;为空则遍历全部仓库)。 + /// + public List? WarehouseIds { get; set; } + /// /// 目的省份。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs index a303919..6b67581 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckOutput.cs @@ -15,6 +15,21 @@ public class OrderPriceCheckOutput /// public string ShippingMethod { get; set; } = string.Empty; + /// + /// 货物类型编码(字典 cargo_attribute)。 + /// + public string? CargoType { get; set; } + + /// + /// 货物类型名称(展示用)。 + /// + public string? CargoTypeName { get; set; } + + /// + /// 仓库主键。 + /// + public int? WarehouseId { get; set; } + /// /// 仓库名称。 /// diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequest.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequest.cs index 79c71fa..726d2ce 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequest.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequest.cs @@ -17,6 +17,9 @@ public class SmallBagTrackUpsertRequest public string Product { get; set; } = string.Empty; + /// + /// 轨迹服务商 code(对应轨迹 ServiceProviderCode,取订单 DownstreamSupplierCode)。 + /// public string ApiCode { get; set; } = string.Empty; public SmallBagTrackConsigneeRequest Consignee { get; set; } = new(); diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index fc948e4..ad04976 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -102,7 +102,7 @@ public class CustomerService : ApplicationService [HttpPost("/customer/create")] public async Task CreateAsync(CreateCustomerInput input) { - var normalized = NormalizeCustomerInput(input, "客户编号", "客户名称"); + var normalized = await NormalizeCreateCustomerInputAsync(input, "客户编号", "客户名称"); await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email); @@ -134,7 +134,7 @@ public class CustomerService : ApplicationService var mainCustomer = await GetCurrentMainCustomerOrThrowAsync(); ValidateCustomerStatus(mainCustomer); - var normalized = NormalizeCustomerInput(input, "子客户编号", "子客户名称"); + var normalized = await NormalizeCreateCustomerInputAsync(input, "子客户编号", "子客户名称"); var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions); await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email); @@ -314,7 +314,7 @@ public class CustomerService : ApplicationService return customer; } - private async Task EnsureCustomerIdentityUniqueAsync(string customerCode, string phone, string? email) + private async Task EnsureCustomerIdentityUniqueAsync(string customerCode, string phone, string email) { if (await CustomerRepository.GetByCustomerCodeAsync(customerCode) != null) { @@ -326,7 +326,7 @@ public class CustomerService : ApplicationService throw new BusinessException("手机号已存在"); } - if (email != null && await CustomerRepository.GetByEmailAsync(email) != null) + if (await CustomerRepository.GetByEmailAsync(email) != null) { throw new BusinessException("邮箱已存在"); } @@ -399,16 +399,20 @@ public class CustomerService : ApplicationService }; } - private static NormalizedCustomerInput NormalizeCustomerInput( + private async Task NormalizeCreateCustomerInputAsync( CreateCustomerInput input, string codeFieldName, string nameFieldName) { + var customerCode = string.IsNullOrWhiteSpace(input.CustomerCode) + ? await CustomerCodeGenerator.GenerateNextAsync(CustomerRepository) + : NormalizeRequiredText(input.CustomerCode, codeFieldName); + return new NormalizedCustomerInput( - NormalizeRequiredText(input.CustomerCode, codeFieldName), + customerCode, NormalizeRequiredText(input.CustomerName, nameFieldName), NormalizeRequiredText(input.Phone, "手机号"), - NormalizeOptionalText(input.Email)); + NormalizeRequiredText(input.Email, "邮箱")); } private async Task> NormalizeAndValidateSubCustomerPermissionsAsync(IEnumerable? permissions) @@ -499,5 +503,5 @@ public class CustomerService : ApplicationService string CustomerCode, string CustomerName, string Phone, - string? Email); + string Email); } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 928b291..6226fb8 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -1489,12 +1489,7 @@ public partial class OrderService var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); var recipient = recipients.FirstOrDefault(); - var apiCode = order.ChannelForecastCode?.Trim() ?? string.Empty; - if (string.IsNullOrWhiteSpace(apiCode) && !string.IsNullOrWhiteSpace(order.ChannelCode)) - { - var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim()); - apiCode = channel?.ForecastCode?.Trim() ?? string.Empty; - } + var apiCode = order.DownstreamSupplierCode?.Trim() ?? string.Empty; return new SmallBagTrackUpsertRequest { diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 460637b..8c585ec 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -9,18 +9,21 @@ namespace Oms.Application.Services.Orders; public partial class OrderService { + private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute); + private async Task BuildPriceCheckResultAsync(OrderPriceCheckInput input) { ValidatePriceCheckInput(input); var bindTime = DateTime.Now; var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry); - var cargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoType); - var quoteContext = new ParcelQuoteContext + var cargoTargets = ResolvePriceCheckCargoTargets(input); + var warehouses = await ResolvePriceCheckWarehousesAsync(input); + + var baseContext = new ParcelQuoteContext { CountryCode = countryCode, Province = input.DestinationProvince, PostalCode = input.PostalCode, - CargoAttribute = cargoAttribute, Length = input.Length!.Value, Width = input.Width!.Value, Height = input.Height!.Value, @@ -48,47 +51,120 @@ public partial class OrderService return result; } - var warehouses = await WarehouseAddressRepository.GetListAsync(); - var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault(); - foreach (var product in products.OrderBy(x => x.Id)) { - var attempt = await TryComputePriceCheckQuoteAsync(product, quoteContext, customerGroup, bindTime); - if (attempt.Success) + foreach (var cargo in cargoTargets) { - var quote = attempt.Quote!; - var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees); - result.Items.Add(new OrderPriceCheckOutput + foreach (var warehouse in warehouses) { - Id = quote.ProductId, - ProductCode = quote.ProductCode, - ShippingMethod = quote.ProductName, - Warehouse = defaultWarehouse?.WarehouseName, - DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", - VolumetricWeight = quote.VolumetricWeight, - ChargeableWeight = quote.ChargeableWeight, - CostFormula = costFormula, - Freight = freight, - OtherFees = otherFees, - TotalCost = totalCost, - MaxDeliveryDays = product.MaxTimeliness, - TotalCostNum = totalCost, - RulesVersion = quote.RulesVersion, - PriceVersion = quote.PriceVersion, - IsRemote = quote.IsRemote - }); - continue; - } + var quoteContext = new ParcelQuoteContext + { + CountryCode = baseContext.CountryCode, + Province = baseContext.Province, + PostalCode = baseContext.PostalCode, + CargoAttribute = cargo.CargoAttribute, + CargoTypeCode = cargo.Code, + WarehouseId = warehouse.Id, + WarehouseName = warehouse.WarehouseName, + Length = baseContext.Length, + Width = baseContext.Width, + Height = baseContext.Height, + ActualWeight = baseContext.ActualWeight + }; - if (attempt.Failure != null) - { - result.Failures.Add(attempt.Failure); + var attempt = await TryComputePriceCheckQuoteAsync( + product, + quoteContext, + customerGroup, + bindTime, + cargo.DisplayName, + warehouse.WarehouseName); + + if (attempt.Success) + { + var quote = attempt.Quote!; + var (freight, otherFees, totalCost, costFormula) = ParcelBillingCalculator.SummarizeFees(quote.Fees); + result.Items.Add(new OrderPriceCheckOutput + { + Id = quote.ProductId, + ProductCode = quote.ProductCode, + ShippingMethod = quote.ProductName, + CargoType = cargo.Code, + CargoTypeName = cargo.DisplayName, + WarehouseId = warehouse.Id, + Warehouse = warehouse.WarehouseName, + DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", + VolumetricWeight = quote.VolumetricWeight, + ChargeableWeight = quote.ChargeableWeight, + CostFormula = costFormula, + Freight = freight, + OtherFees = otherFees, + TotalCost = totalCost, + MaxDeliveryDays = product.MaxTimeliness, + TotalCostNum = totalCost, + RulesVersion = quote.RulesVersion, + PriceVersion = quote.PriceVersion, + IsRemote = quote.IsRemote + }); + continue; + } + + if (attempt.Failure != null) + { + result.Failures.Add(attempt.Failure); + } + } } } return result; } + private static List ResolvePriceCheckCargoTargets(OrderPriceCheckInput input) + { + var codes = (input.CargoTypes ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (codes.Count == 0 && !string.IsNullOrWhiteSpace(input.CargoType)) + { + codes.Add(input.CargoType.Trim()); + } + + if (codes.Count == 0) + { + codes.Add(string.Empty); + } + + return codes.Select(code => + { + var attribute = ParcelBillingCalculator.MapCargoTypeToAttribute(code); + var display = string.IsNullOrWhiteSpace(code) + ? ParcelBillingCalculator.GetCargoAttributeLabel(attribute) + : code; + return new PriceCheckCargoTarget(code, display, attribute); + }).ToList(); + } + + private async Task> ResolvePriceCheckWarehousesAsync(OrderPriceCheckInput input) + { + var ids = (input.WarehouseIds ?? []) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count > 0) + { + var selected = await WarehouseAddressRepository.GetListAsync(x => ids.Contains(x.Id)); + return selected.OrderBy(x => x.Id).ToList(); + } + + var all = await WarehouseAddressRepository.GetListAsync(); + return all.OrderBy(x => x.Id).ToList(); + } + private static void ValidatePriceCheckInput(OrderPriceCheckInput input) { if (string.IsNullOrWhiteSpace(input.DestinationCountry)) @@ -177,11 +253,13 @@ public partial class OrderService SalesProduct product, ParcelQuoteContext context, CustomerGroup? customerGroup, - DateTime bindTime) + DateTime bindTime, + string cargoTypeName, + string warehouseName) { if (product.IsActive != EnableStatus.Enabled) { - return PriceCheckFailure(product, "product", "产品未启用"); + return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName); } ProductSnapshotBinding snapshotBinding; @@ -191,7 +269,7 @@ public partial class OrderService } catch (BusinessException ex) { - return PriceCheckFailure(product, "snapshot", ex.Message); + return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName); } if (await IsBlacklistHitAsync( @@ -205,7 +283,9 @@ public partial class OrderService return PriceCheckFailure( product, "blacklist", - $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单"); + $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单", + cargoTypeName, + warehouseName); } var forecast = ParcelBillingCalculator.BuildForecastWeight(context); @@ -215,7 +295,7 @@ public partial class OrderService } catch (BusinessException ex) { - return PriceCheckFailure(product, "weight", ex.Message); + return PriceCheckFailure(product, "weight", ex.Message, cargoTypeName, warehouseName); } var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price); @@ -228,7 +308,9 @@ public partial class OrderService return PriceCheckFailure( product, "zone", - $"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键"); + $"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键", + cargoTypeName, + warehouseName); } var zoneKeys = await ResolvePriceZoneKeysAsync( @@ -249,7 +331,9 @@ public partial class OrderService product, "zone", $"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," + - "请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致"); + "请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致", + cargoTypeName, + warehouseName); } var chargeableWeight = forecast.ChargeableWeight ?? 0; @@ -260,13 +344,15 @@ public partial class OrderService chargeableWeight, zoneKeys, context.CargoAttribute, - customerGroup); + customerGroup, + context.WarehouseId, + context.WarehouseName); if (fees.Count == 0) { if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price)) { - return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项"); + return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName); } return PriceCheckFailure( @@ -277,7 +363,11 @@ public partial class OrderService chargeableWeight, zoneKeys, context.CargoAttribute, - customerGroup)); + customerGroup, + context.WarehouseId, + context.WarehouseName), + cargoTypeName, + warehouseName); } var isRemote = await IsRemoteHitAsync( @@ -306,7 +396,12 @@ public partial class OrderService }; } - private static PriceCheckAttemptResult PriceCheckFailure(SalesProduct product, string step, string reason) => + private static PriceCheckAttemptResult PriceCheckFailure( + SalesProduct product, + string step, + string reason, + string? cargoTypeName = null, + string? warehouseName = null) => new() { Failure = new OrderPriceCheckFailureOutput @@ -315,10 +410,31 @@ public partial class OrderService ProductCode = product.ProductCode, ProductName = product.ProductName, Step = step, - Reason = reason + CargoTypeName = cargoTypeName, + WarehouseName = warehouseName, + Reason = PrefixPriceCheckFailureReason(cargoTypeName, warehouseName, reason) } }; + private static string PrefixPriceCheckFailureReason( + string? cargoTypeName, + string? warehouseName, + string reason) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(cargoTypeName)) + { + parts.Add($"货物类型={cargoTypeName.Trim()}"); + } + + if (!string.IsNullOrWhiteSpace(warehouseName)) + { + parts.Add($"仓库={warehouseName.Trim()}"); + } + + return parts.Count == 0 ? reason : $"[{string.Join(",", parts)}] {reason}"; + } + private static string FormatPostalCode(string? postalCode) => string.IsNullOrWhiteSpace(postalCode) ? "-" : postalCode.Trim(); } diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index a81786d..64c3b38 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -353,7 +353,9 @@ internal static class ParcelBillingCalculator decimal chargeableWeight, IReadOnlyList zoneKeys, string cargoAttribute, - CustomerGroup? customerGroup) => + CustomerGroup? customerGroup, + int? warehouseId = null, + string? warehouseName = null) => CalculateReceivableFees( priceSnapshot, priceVersion, @@ -362,7 +364,9 @@ internal static class ParcelBillingCalculator zoneKeys, cargoAttribute, customerGroup, - OrderFeeChargingStageCodes.Forecast); + OrderFeeChargingStageCodes.Forecast, + warehouseId, + warehouseName); public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => HasEnabledReceivablePriceItems(priceSnapshot); @@ -399,9 +403,12 @@ internal static class ParcelBillingCalculator decimal chargeableWeight, IReadOnlyList zoneKeys, string cargoAttribute, - CustomerGroup? customerGroup) + CustomerGroup? customerGroup, + int? warehouseId = null, + string? warehouseName = null) { var cargoLabel = GetCargoAttributeLabel(cargoAttribute); + var warehouseLabel = FormatWarehouseLabel(warehouseId, warehouseName); var enabledPrices = priceSnapshot.Prices.Where(x => x.Status == 1).ToList(); if (enabledPrices.Count == 0) { @@ -462,9 +469,31 @@ internal static class ParcelBillingCalculator continue; } + var warehouseMatchedDetails = cargoMatchedDetails.Where(detail => + { + var factors = ParseFactorDictionary(detail.FactorValuesJson); + return MatchWarehouseFactor(factors, warehouseId, warehouseName); + }).ToList(); + + if (warehouseMatchedDetails.Count == 0) + { + var requiredWarehouse = cargoMatchedDetails + .Select(detail => + { + var factors = ParseFactorDictionary(detail.FactorValuesJson); + return TryGetFactorValue(factors, out var wh, "仓库", "warehouse") ? wh.Trim() : null; + }) + .Where(wh => !string.IsNullOrWhiteSpace(wh)) + .Distinct(StringComparer.OrdinalIgnoreCase); + var requiredText = requiredWarehouse.Any() ? string.Join(", ", requiredWarehouse) : "(明细未限定仓库)"; + reasons.Add( + $"【{label}】价格项Id={price.Id}:仓库不匹配,当前={warehouseLabel},明细要求=[{requiredText}]"); + continue; + } + var weightMatched = false; var intervals = new List(); - foreach (var detail in cargoMatchedDetails) + foreach (var detail in warehouseMatchedDetails) { var factors = ParseFactorDictionary(detail.FactorValuesJson); if (!TryGetWeightFactorText(factors, out var weightText)) @@ -547,7 +576,9 @@ internal static class ParcelBillingCalculator IReadOnlyList zoneKeys, string cargoAttribute, CustomerGroup? customerGroup, - string chargingStage) + string chargingStage, + int? warehouseId = null, + string? warehouseName = null) { var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem( p.Status, p.CostItem, p.Currency, p.Details)); @@ -559,7 +590,9 @@ internal static class ParcelBillingCalculator zoneKeys, cargoAttribute, customerGroup, - chargingStage); + chargingStage, + warehouseId, + warehouseName); } /// @@ -697,12 +730,20 @@ internal static class ParcelBillingCalculator IReadOnlyList zoneKeys, string cargoAttribute, CustomerGroup? customerGroup, - string chargingStage) + string chargingStage, + int? warehouseId = null, + string? warehouseName = null) { var fees = new List(); foreach (var priceItem in priceItems.Where(x => x.Status == 1)) { - var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight); + var detail = MatchPriceDetail( + priceItem.Details, + zoneKeys, + cargoAttribute, + chargeableWeight, + warehouseId, + warehouseName); if (detail == null) { continue; @@ -714,7 +755,9 @@ internal static class ParcelBillingCalculator zoneKeys, cargoAttribute, chargeableWeight, - customerGroup); + customerGroup, + warehouseId, + warehouseName); fees.Add(new OrderFee { Type = OrderFeeTypeCodes.Receivable, @@ -738,12 +781,20 @@ internal static class ParcelBillingCalculator IReadOnlyList zoneKeys, string cargoAttribute, CustomerGroup? customerGroup, - string chargingStage) + string chargingStage, + int? warehouseId = null, + string? warehouseName = null) { var fees = new List(); foreach (var priceItem in priceItems.Where(x => x.Status == 1)) { - var detail = MatchPriceDetail(priceItem.Details, zoneKeys, cargoAttribute, chargeableWeight); + var detail = MatchPriceDetail( + priceItem.Details, + zoneKeys, + cargoAttribute, + chargeableWeight, + warehouseId, + warehouseName); if (detail == null) { continue; @@ -755,7 +806,9 @@ internal static class ParcelBillingCalculator zoneKeys, cargoAttribute, chargeableWeight, - customerGroup); + customerGroup, + warehouseId, + warehouseName); fees.Add(new OrderFee { Type = OrderFeeTypeCodes.Payable, @@ -787,7 +840,9 @@ internal static class ParcelBillingCalculator IReadOnlyList details, IReadOnlyList zoneKeys, string cargoAttribute, - decimal chargeableWeight) + decimal chargeableWeight, + int? warehouseId = null, + string? warehouseName = null) { var cargoLabel = GetCargoAttributeLabel(cargoAttribute); ChannelPriceDetailOutput? bestMatch = null; @@ -806,6 +861,11 @@ internal static class ParcelBillingCalculator continue; } + if (!MatchWarehouseFactor(factors, warehouseId, warehouseName)) + { + continue; + } + if (!TryGetWeightFactorText(factors, out var weightText)) { if (bestMatch == null) @@ -911,6 +971,41 @@ internal static class ParcelBillingCalculator || string.Equals(actual.Trim(), cargoLabel, StringComparison.Ordinal); } + private static bool MatchWarehouseFactor( + Dictionary factors, + int? warehouseId, + string? warehouseName) + { + if (!TryGetFactorValue(factors, out var actual, "仓库", "warehouse") || string.IsNullOrWhiteSpace(actual)) + { + return true; + } + + var expected = actual.Trim(); + if (warehouseId.HasValue && string.Equals(expected, warehouseId.Value.ToString(), StringComparison.Ordinal)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(warehouseName) + && string.Equals(expected, warehouseName.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private static string FormatWarehouseLabel(int? warehouseId, string? warehouseName) + { + if (!string.IsNullOrWhiteSpace(warehouseName)) + { + return warehouseName.Trim(); + } + + return warehouseId.HasValue ? warehouseId.Value.ToString() : "未指定"; + } + private static bool TryGetFactorValue( Dictionary factors, out string value, @@ -963,7 +1058,9 @@ internal static class ParcelBillingCalculator IReadOnlyList zoneKeys, string cargoAttribute, decimal chargeableWeight, - CustomerGroup? customerGroup) + CustomerGroup? customerGroup, + int? warehouseId = null, + string? warehouseName = null) { var cargoLabel = GetCargoAttributeLabel(cargoAttribute); return NormalizeCalculationFormula(detail.CalculationFormula) switch @@ -976,7 +1073,9 @@ internal static class ParcelBillingCalculator zoneKeys, cargoLabel, chargeableWeight, - customerGroup), + customerGroup, + warehouseId, + warehouseName), _ => detail.CalculatedValue ?? 0 }; } @@ -1002,7 +1101,9 @@ internal static class ParcelBillingCalculator IReadOnlyList zoneKeys, string cargoLabel, decimal chargeableWeight, - CustomerGroup? customerGroup) + CustomerGroup? customerGroup, + int? warehouseId = null, + string? warehouseName = null) { var factors = ParseFactorDictionary(detail.FactorValuesJson); if (TryGetWeightFactorText(factors, out var weightText) @@ -1013,6 +1114,8 @@ internal static class ParcelBillingCalculator cargoLabel, rangeStart, customerGroup, + warehouseId, + warehouseName, out var basePrice)) { var continueUnitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0; @@ -1039,13 +1142,17 @@ internal static class ParcelBillingCalculator string cargoLabel, decimal continueRangeStart, CustomerGroup? customerGroup, + int? warehouseId, + string? warehouseName, out decimal basePrice) { basePrice = 0; foreach (var candidate in allDetails) { var factors = ParseFactorDictionary(candidate.FactorValuesJson); - if (!ZoneMatches(factors, zoneKeys) || !MatchCargoTypeFactor(factors, cargoLabel)) + if (!ZoneMatches(factors, zoneKeys) + || !MatchCargoTypeFactor(factors, cargoLabel) + || !MatchWarehouseFactor(factors, warehouseId, warehouseName)) { continue; } diff --git a/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs b/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs index 4c8ea33..357469a 100644 --- a/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs +++ b/src/Oms.Application/Services/Orders/ParcelQuoteContext.cs @@ -15,6 +15,13 @@ internal sealed class ParcelQuoteContext public string CargoAttribute { get; init; } = string.Empty; + /// 查价入参原始货物类型编码(字典 cargo_attribute)。 + public string? CargoTypeCode { get; init; } + + public int? WarehouseId { get; init; } + + public string? WarehouseName { get; init; } + public decimal Length { get; init; } public decimal Width { get; init; } diff --git a/src/Oms.Application/Shared/CustomerCodeGenerator.cs b/src/Oms.Application/Shared/CustomerCodeGenerator.cs new file mode 100644 index 0000000..9f4fff3 --- /dev/null +++ b/src/Oms.Application/Shared/CustomerCodeGenerator.cs @@ -0,0 +1,44 @@ +using Fake.Application; +using Oms.Domain.CustomersAggregate.Repositories; + +namespace Oms.Application.Shared; + +/// +/// 客户编号生成:W + 年份后2位 + 月份2位 + 3位顺序号,如 W2606001。 +/// +public static class CustomerCodeGenerator +{ + private const int MaxSequence = 999; + private const int MaxRetries = 5; + + public static async Task GenerateNextAsync( + ICustomerRepository repository, + CancellationToken cancellationToken = default) + { + for (var attempt = 0; attempt < MaxRetries; attempt++) + { + var code = await BuildNextCodeAsync(repository, cancellationToken); + if (await repository.GetByCustomerCodeAsync(code, cancellationToken) == null) + { + return code; + } + } + + throw new BusinessException("客户编号生成失败,请重试"); + } + + private static async Task BuildNextCodeAsync( + ICustomerRepository repository, + CancellationToken cancellationToken) + { + var prefix = $"W{DateTime.Now:yyMM}"; + var maxSeq = await repository.GetMaxCustomerCodeSequenceForPrefixAsync(prefix, cancellationToken); + var next = maxSeq + 1; + if (next > MaxSequence) + { + throw new BusinessException($"当月客户编号已达上限({prefix}xxx)"); + } + + return $"{prefix}{next:D3}"; + } +} diff --git a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs index 1b7cc08..d22c4c9 100644 --- a/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs +++ b/src/Oms.Domain/CustomersAggregate/Repositories/ICustomerRepository.cs @@ -14,4 +14,9 @@ public interface ICustomerRepository : IRepository Task GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default); Task ExistsPhoneAsync(string phone, CancellationToken cancellationToken = default); + + /// + /// 查询指定前缀(WyyMM)下已用顺序号的最大值。 + /// + Task GetMaxCustomerCodeSequenceForPrefixAsync(string prefix, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs index f0a4821..3304742 100644 --- a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs +++ b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs @@ -31,4 +31,33 @@ public class CustomerRepository : OmsSqlSugarRepository, ICustomerRepo { return AnyAsync(x => x.Phone == phone, cancellationToken); } + + public async Task GetMaxCustomerCodeSequenceForPrefixAsync( + string prefix, + CancellationToken cancellationToken = default) + { + var expectedLength = prefix.Length + 3; + var db = await GetDbContextAsync(cancellationToken); + var codes = await db.Queryable() + .Where(x => x.CustomerCode.StartsWith(prefix) && x.CustomerCode.Length == expectedLength) + .Select(x => x.CustomerCode) + .ToListAsync(cancellationToken); + + var max = 0; + foreach (var code in codes) + { + if (code.Length <= prefix.Length) + { + continue; + } + + var suffix = code[prefix.Length..]; + if (int.TryParse(suffix, out var seq) && seq > max) + { + max = seq; + } + } + + return max; + } } diff --git a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs index 9b8922d..3e5be5c 100644 --- a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs +++ b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs @@ -50,8 +50,7 @@ public class WeChatProfileInput public class WeChatCustomerRegisterInput { - [Required] - public string CustomerCode { get; set; } = string.Empty; + public string? CustomerCode { get; set; } [Required] public string CustomerName { get; set; } = string.Empty; @@ -59,7 +58,9 @@ public class WeChatCustomerRegisterInput [Required] public string Phone { get; set; } = string.Empty; - public string? Email { get; set; } + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; [Required] [MinLength(6)] diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs index 7d30448..de993f9 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs @@ -80,10 +80,9 @@ public class WeChatCustomerAppService( { ValidateWechatProfile(input.Wechat); - var customerCode = NormalizeRequired(input.CustomerCode, "客户编号"); var customerName = NormalizeRequired(input.CustomerName, "客户名称"); var phone = NormalizeRequired(input.Phone, "手机号"); - var email = NormalizeOptional(input.Email); + var email = NormalizeRequired(input.Email, "邮箱"); var password = NormalizeRequired(input.Password, "密码"); if (password.Length < 6) @@ -96,6 +95,7 @@ public class WeChatCustomerAppService( throw new BusinessException("当前微信已绑定账号"); } + var customerCode = await CustomerCodeGenerator.GenerateNextAsync(customerRepository, cancellationToken); await EnsureCustomerIdentityUniqueAsync(customerCode, phone, email, cancellationToken); var customerRoleId = await GetCustomerRoleIdOrThrowAsync(cancellationToken); @@ -193,7 +193,7 @@ public class WeChatCustomerAppService( private async Task EnsureCustomerIdentityUniqueAsync( string customerCode, string phone, - string? email, + string email, CancellationToken cancellationToken) { if (await customerRepository.GetByCustomerCodeAsync(customerCode, cancellationToken) != null) @@ -206,7 +206,7 @@ public class WeChatCustomerAppService( throw new BusinessException("手机号已存在"); } - if (email != null && await customerRepository.GetByEmailAsync(email, cancellationToken) != null) + if (await customerRepository.GetByEmailAsync(email, cancellationToken) != null) { throw new BusinessException("邮箱已存在"); }