feat: price-check by cargo type and warehouse, customer auto-code, track apiCode

Support multi cargo-type and warehouse combinations in price check billing. Auto-generate customer codes (W+yyMM+seq) on create/register; require email. Use order DownstreamSupplierCode as track UpsertSmallBag apiCode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-29 18:40:10 +08:00
parent bc0bde72bc
commit e875f0c012
15 changed files with 434 additions and 88 deletions

View File

@ -9,11 +9,10 @@ namespace Oms.Application.Dtos.Customers;
public class CreateCustomerInput public class CreateCustomerInput
{ {
/// <summary> /// <summary>
/// 客户编号。 /// 客户编号(留空时系统自动生成,规则 W+年份后2位+月份2位+3位顺序)。
/// </summary> /// </summary>
[Required]
[StringLength(32)] [StringLength(32)]
public string CustomerCode { get; set; } = string.Empty; public string? CustomerCode { get; set; }
/// <summary> /// <summary>
/// 客户名称。 /// 客户名称。
@ -32,9 +31,10 @@ public class CreateCustomerInput
/// <summary> /// <summary>
/// 邮箱。 /// 邮箱。
/// </summary> /// </summary>
[Required]
[EmailAddress] [EmailAddress]
[StringLength(64)] [StringLength(64)]
public string? Email { get; set; } public string Email { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 微信头像地址。 /// 微信头像地址。

View File

@ -29,4 +29,14 @@ public class OrderPriceCheckFailureOutput
/// 未命中原因说明。 /// 未命中原因说明。
/// </summary> /// </summary>
public string Reason { get; set; } = string.Empty; public string Reason { get; set; } = string.Empty;
/// <summary>
/// 货物类型名称(展示)。
/// </summary>
public string? CargoTypeName { get; set; }
/// <summary>
/// 仓库名称(展示)。
/// </summary>
public string? WarehouseName { get; set; }
} }

View File

@ -36,10 +36,20 @@ public class OrderPriceCheckInput
public decimal? Height { get; set; } public decimal? Height { get; set; }
/// <summary> /// <summary>
/// 货物类型。 /// 货物类型(单选,兼容旧版;请优先使用 CargoTypes)。
/// </summary> /// </summary>
public string? CargoType { get; set; } public string? CargoType { get; set; }
/// <summary>
/// 货物类型(多选,字典 cargo_attribute 编码)。
/// </summary>
public List<string>? CargoTypes { get; set; }
/// <summary>
/// 仓库主键(多选;为空则遍历全部仓库)。
/// </summary>
public List<int>? WarehouseIds { get; set; }
/// <summary> /// <summary>
/// 目的省份。 /// 目的省份。
/// </summary> /// </summary>

View File

@ -15,6 +15,21 @@ public class OrderPriceCheckOutput
/// </summary> /// </summary>
public string ShippingMethod { get; set; } = string.Empty; public string ShippingMethod { get; set; } = string.Empty;
/// <summary>
/// 货物类型编码(字典 cargo_attribute)。
/// </summary>
public string? CargoType { get; set; }
/// <summary>
/// 货物类型名称(展示用)。
/// </summary>
public string? CargoTypeName { get; set; }
/// <summary>
/// 仓库主键。
/// </summary>
public int? WarehouseId { get; set; }
/// <summary> /// <summary>
/// 仓库名称。 /// 仓库名称。
/// </summary> /// </summary>

View File

@ -17,6 +17,9 @@ public class SmallBagTrackUpsertRequest
public string Product { get; set; } = string.Empty; public string Product { get; set; } = string.Empty;
/// <summary>
/// 轨迹服务商 code(对应轨迹 ServiceProviderCode,取订单 DownstreamSupplierCode)。
/// </summary>
public string ApiCode { get; set; } = string.Empty; public string ApiCode { get; set; } = string.Empty;
public SmallBagTrackConsigneeRequest Consignee { get; set; } = new(); public SmallBagTrackConsigneeRequest Consignee { get; set; } = new();

View File

@ -102,7 +102,7 @@ public class CustomerService : ApplicationService
[HttpPost("/customer/create")] [HttpPost("/customer/create")]
public async Task CreateAsync(CreateCustomerInput input) public async Task CreateAsync(CreateCustomerInput input)
{ {
var normalized = NormalizeCustomerInput(input, "客户编号", "客户名称"); var normalized = await NormalizeCreateCustomerInputAsync(input, "客户编号", "客户名称");
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email); await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
@ -134,7 +134,7 @@ public class CustomerService : ApplicationService
var mainCustomer = await GetCurrentMainCustomerOrThrowAsync(); var mainCustomer = await GetCurrentMainCustomerOrThrowAsync();
ValidateCustomerStatus(mainCustomer); ValidateCustomerStatus(mainCustomer);
var normalized = NormalizeCustomerInput(input, "子客户编号", "子客户名称"); var normalized = await NormalizeCreateCustomerInputAsync(input, "子客户编号", "子客户名称");
var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions); var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions);
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email); await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
@ -314,7 +314,7 @@ public class CustomerService : ApplicationService
return customer; 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) if (await CustomerRepository.GetByCustomerCodeAsync(customerCode) != null)
{ {
@ -326,7 +326,7 @@ public class CustomerService : ApplicationService
throw new BusinessException("手机号已存在"); throw new BusinessException("手机号已存在");
} }
if (email != null && await CustomerRepository.GetByEmailAsync(email) != null) if (await CustomerRepository.GetByEmailAsync(email) != null)
{ {
throw new BusinessException("邮箱已存在"); throw new BusinessException("邮箱已存在");
} }
@ -399,16 +399,20 @@ public class CustomerService : ApplicationService
}; };
} }
private static NormalizedCustomerInput NormalizeCustomerInput( private async Task<NormalizedCustomerInput> NormalizeCreateCustomerInputAsync(
CreateCustomerInput input, CreateCustomerInput input,
string codeFieldName, string codeFieldName,
string nameFieldName) string nameFieldName)
{ {
var customerCode = string.IsNullOrWhiteSpace(input.CustomerCode)
? await CustomerCodeGenerator.GenerateNextAsync(CustomerRepository)
: NormalizeRequiredText(input.CustomerCode, codeFieldName);
return new NormalizedCustomerInput( return new NormalizedCustomerInput(
NormalizeRequiredText(input.CustomerCode, codeFieldName), customerCode,
NormalizeRequiredText(input.CustomerName, nameFieldName), NormalizeRequiredText(input.CustomerName, nameFieldName),
NormalizeRequiredText(input.Phone, "手机号"), NormalizeRequiredText(input.Phone, "手机号"),
NormalizeOptionalText(input.Email)); NormalizeRequiredText(input.Email, "邮箱"));
} }
private async Task<List<string>> NormalizeAndValidateSubCustomerPermissionsAsync(IEnumerable<string>? permissions) private async Task<List<string>> NormalizeAndValidateSubCustomerPermissionsAsync(IEnumerable<string>? permissions)
@ -499,5 +503,5 @@ public class CustomerService : ApplicationService
string CustomerCode, string CustomerCode,
string CustomerName, string CustomerName,
string Phone, string Phone,
string? Email); string Email);
} }

View File

@ -1489,12 +1489,7 @@ public partial class OrderService
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]);
var recipient = recipients.FirstOrDefault(); var recipient = recipients.FirstOrDefault();
var apiCode = order.ChannelForecastCode?.Trim() ?? string.Empty; var apiCode = order.DownstreamSupplierCode?.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;
}
return new SmallBagTrackUpsertRequest return new SmallBagTrackUpsertRequest
{ {

View File

@ -9,18 +9,21 @@ namespace Oms.Application.Services.Orders;
public partial class OrderService public partial class OrderService
{ {
private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute);
private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input) private async Task<OrderPriceCheckResultOutput> BuildPriceCheckResultAsync(OrderPriceCheckInput input)
{ {
ValidatePriceCheckInput(input); ValidatePriceCheckInput(input);
var bindTime = DateTime.Now; var bindTime = DateTime.Now;
var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry); var countryCode = await ResolveDestinationCountryCodeAsync(input.DestinationCountry);
var cargoAttribute = ParcelBillingCalculator.MapCargoTypeToAttribute(input.CargoType); var cargoTargets = ResolvePriceCheckCargoTargets(input);
var quoteContext = new ParcelQuoteContext var warehouses = await ResolvePriceCheckWarehousesAsync(input);
var baseContext = new ParcelQuoteContext
{ {
CountryCode = countryCode, CountryCode = countryCode,
Province = input.DestinationProvince, Province = input.DestinationProvince,
PostalCode = input.PostalCode, PostalCode = input.PostalCode,
CargoAttribute = cargoAttribute,
Length = input.Length!.Value, Length = input.Length!.Value,
Width = input.Width!.Value, Width = input.Width!.Value,
Height = input.Height!.Value, Height = input.Height!.Value,
@ -48,12 +51,35 @@ public partial class OrderService
return result; return result;
} }
var warehouses = await WarehouseAddressRepository.GetListAsync();
var defaultWarehouse = warehouses.OrderBy(x => x.Id).FirstOrDefault();
foreach (var product in products.OrderBy(x => x.Id)) foreach (var product in products.OrderBy(x => x.Id))
{ {
var attempt = await TryComputePriceCheckQuoteAsync(product, quoteContext, customerGroup, bindTime); foreach (var cargo in cargoTargets)
{
foreach (var warehouse in warehouses)
{
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
};
var attempt = await TryComputePriceCheckQuoteAsync(
product,
quoteContext,
customerGroup,
bindTime,
cargo.DisplayName,
warehouse.WarehouseName);
if (attempt.Success) if (attempt.Success)
{ {
var quote = attempt.Quote!; var quote = attempt.Quote!;
@ -63,7 +89,10 @@ public partial class OrderService
Id = quote.ProductId, Id = quote.ProductId,
ProductCode = quote.ProductCode, ProductCode = quote.ProductCode,
ShippingMethod = quote.ProductName, ShippingMethod = quote.ProductName,
Warehouse = defaultWarehouse?.WarehouseName, CargoType = cargo.Code,
CargoTypeName = cargo.DisplayName,
WarehouseId = warehouse.Id,
Warehouse = warehouse.WarehouseName,
DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}", DeliveryTime = $"{product.MinTimeliness ?? 0}-{product.MaxTimeliness ?? 0}",
VolumetricWeight = quote.VolumetricWeight, VolumetricWeight = quote.VolumetricWeight,
ChargeableWeight = quote.ChargeableWeight, ChargeableWeight = quote.ChargeableWeight,
@ -85,10 +114,57 @@ public partial class OrderService
result.Failures.Add(attempt.Failure); result.Failures.Add(attempt.Failure);
} }
} }
}
}
return result; return result;
} }
private static List<PriceCheckCargoTarget> 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<List<WarehouseAddress>> 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) private static void ValidatePriceCheckInput(OrderPriceCheckInput input)
{ {
if (string.IsNullOrWhiteSpace(input.DestinationCountry)) if (string.IsNullOrWhiteSpace(input.DestinationCountry))
@ -177,11 +253,13 @@ public partial class OrderService
SalesProduct product, SalesProduct product,
ParcelQuoteContext context, ParcelQuoteContext context,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
DateTime bindTime) DateTime bindTime,
string cargoTypeName,
string warehouseName)
{ {
if (product.IsActive != EnableStatus.Enabled) if (product.IsActive != EnableStatus.Enabled)
{ {
return PriceCheckFailure(product, "product", "产品未启用"); return PriceCheckFailure(product, "product", "产品未启用", cargoTypeName, warehouseName);
} }
ProductSnapshotBinding snapshotBinding; ProductSnapshotBinding snapshotBinding;
@ -191,7 +269,7 @@ public partial class OrderService
} }
catch (BusinessException ex) catch (BusinessException ex)
{ {
return PriceCheckFailure(product, "snapshot", ex.Message); return PriceCheckFailure(product, "snapshot", ex.Message, cargoTypeName, warehouseName);
} }
if (await IsBlacklistHitAsync( if (await IsBlacklistHitAsync(
@ -205,7 +283,9 @@ public partial class OrderService
return PriceCheckFailure( return PriceCheckFailure(
product, product,
"blacklist", "blacklist",
$"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单"); $"目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 命中产品黑名单",
cargoTypeName,
warehouseName);
} }
var forecast = ParcelBillingCalculator.BuildForecastWeight(context); var forecast = ParcelBillingCalculator.BuildForecastWeight(context);
@ -215,7 +295,7 @@ public partial class OrderService
} }
catch (BusinessException ex) 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); var priceAreaId = ResolveEffectivePriceAreaId(product, snapshotBinding.Rules, snapshotBinding.Price);
@ -228,7 +308,9 @@ public partial class OrderService
return PriceCheckFailure( return PriceCheckFailure(
product, product,
"zone", "zone",
$"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键"); $"{snapshotZoneHint}产品未配置价格分区(PriceAreaId),无法根据国家+邮编匹配卖价明细中的分区键",
cargoTypeName,
warehouseName);
} }
var zoneKeys = await ResolvePriceZoneKeysAsync( var zoneKeys = await ResolvePriceZoneKeysAsync(
@ -249,7 +331,9 @@ public partial class OrderService
product, product,
"zone", "zone",
$"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," + $"{snapshotZoneHint}目的国 {context.CountryCode} 邮编 {FormatPostalCode(context.PostalCode)} 未命中价格快照内分区规则(PriceAreaId={priceAreaId})," +
"请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致"); "请检查发布时的分区国家/邮编配置是否与卖价明细分区键一致",
cargoTypeName,
warehouseName);
} }
var chargeableWeight = forecast.ChargeableWeight ?? 0; var chargeableWeight = forecast.ChargeableWeight ?? 0;
@ -260,13 +344,15 @@ public partial class OrderService
chargeableWeight, chargeableWeight,
zoneKeys, zoneKeys,
context.CargoAttribute, context.CargoAttribute,
customerGroup); customerGroup,
context.WarehouseId,
context.WarehouseName);
if (fees.Count == 0) if (fees.Count == 0)
{ {
if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price)) if (!ParcelBillingCalculator.HasEnabledQuotePriceItems(snapshotBinding.Price))
{ {
return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项"); return PriceCheckFailure(product, "price", "卖价快照中无启用的价格项", cargoTypeName, warehouseName);
} }
return PriceCheckFailure( return PriceCheckFailure(
@ -277,7 +363,11 @@ public partial class OrderService
chargeableWeight, chargeableWeight,
zoneKeys, zoneKeys,
context.CargoAttribute, context.CargoAttribute,
customerGroup)); customerGroup,
context.WarehouseId,
context.WarehouseName),
cargoTypeName,
warehouseName);
} }
var isRemote = await IsRemoteHitAsync( 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() new()
{ {
Failure = new OrderPriceCheckFailureOutput Failure = new OrderPriceCheckFailureOutput
@ -315,10 +410,31 @@ public partial class OrderService
ProductCode = product.ProductCode, ProductCode = product.ProductCode,
ProductName = product.ProductName, ProductName = product.ProductName,
Step = step, 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<string>();
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) => private static string FormatPostalCode(string? postalCode) =>
string.IsNullOrWhiteSpace(postalCode) ? "-" : postalCode.Trim(); string.IsNullOrWhiteSpace(postalCode) ? "-" : postalCode.Trim();
} }

View File

@ -353,7 +353,9 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight, decimal chargeableWeight,
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup) => CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null) =>
CalculateReceivableFees( CalculateReceivableFees(
priceSnapshot, priceSnapshot,
priceVersion, priceVersion,
@ -362,7 +364,9 @@ internal static class ParcelBillingCalculator
zoneKeys, zoneKeys,
cargoAttribute, cargoAttribute,
customerGroup, customerGroup,
OrderFeeChargingStageCodes.Forecast); OrderFeeChargingStageCodes.Forecast,
warehouseId,
warehouseName);
public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) => public static bool HasEnabledQuotePriceItems(SalesProductPricePublishSnapshotDto priceSnapshot) =>
HasEnabledReceivablePriceItems(priceSnapshot); HasEnabledReceivablePriceItems(priceSnapshot);
@ -399,9 +403,12 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight, decimal chargeableWeight,
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup) CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null)
{ {
var cargoLabel = GetCargoAttributeLabel(cargoAttribute); var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
var warehouseLabel = FormatWarehouseLabel(warehouseId, warehouseName);
var enabledPrices = priceSnapshot.Prices.Where(x => x.Status == 1).ToList(); var enabledPrices = priceSnapshot.Prices.Where(x => x.Status == 1).ToList();
if (enabledPrices.Count == 0) if (enabledPrices.Count == 0)
{ {
@ -462,9 +469,31 @@ internal static class ParcelBillingCalculator
continue; 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 weightMatched = false;
var intervals = new List<string>(); var intervals = new List<string>();
foreach (var detail in cargoMatchedDetails) foreach (var detail in warehouseMatchedDetails)
{ {
var factors = ParseFactorDictionary(detail.FactorValuesJson); var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (!TryGetWeightFactorText(factors, out var weightText)) if (!TryGetWeightFactorText(factors, out var weightText))
@ -547,7 +576,9 @@ internal static class ParcelBillingCalculator
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
string chargingStage) string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{ {
var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem( var items = priceSnapshot.Prices.Select(p => new ReceivableBillingPriceItem(
p.Status, p.CostItem, p.Currency, p.Details)); p.Status, p.CostItem, p.Currency, p.Details));
@ -559,7 +590,9 @@ internal static class ParcelBillingCalculator
zoneKeys, zoneKeys,
cargoAttribute, cargoAttribute,
customerGroup, customerGroup,
chargingStage); chargingStage,
warehouseId,
warehouseName);
} }
/// <summary> /// <summary>
@ -697,12 +730,20 @@ internal static class ParcelBillingCalculator
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
string chargingStage) string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{ {
var fees = new List<OrderFee>(); var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1)) 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) if (detail == null)
{ {
continue; continue;
@ -714,7 +755,9 @@ internal static class ParcelBillingCalculator
zoneKeys, zoneKeys,
cargoAttribute, cargoAttribute,
chargeableWeight, chargeableWeight,
customerGroup); customerGroup,
warehouseId,
warehouseName);
fees.Add(new OrderFee fees.Add(new OrderFee
{ {
Type = OrderFeeTypeCodes.Receivable, Type = OrderFeeTypeCodes.Receivable,
@ -738,12 +781,20 @@ internal static class ParcelBillingCalculator
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
string chargingStage) string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{ {
var fees = new List<OrderFee>(); var fees = new List<OrderFee>();
foreach (var priceItem in priceItems.Where(x => x.Status == 1)) 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) if (detail == null)
{ {
continue; continue;
@ -755,7 +806,9 @@ internal static class ParcelBillingCalculator
zoneKeys, zoneKeys,
cargoAttribute, cargoAttribute,
chargeableWeight, chargeableWeight,
customerGroup); customerGroup,
warehouseId,
warehouseName);
fees.Add(new OrderFee fees.Add(new OrderFee
{ {
Type = OrderFeeTypeCodes.Payable, Type = OrderFeeTypeCodes.Payable,
@ -787,7 +840,9 @@ internal static class ParcelBillingCalculator
IReadOnlyList<ChannelPriceDetailOutput> details, IReadOnlyList<ChannelPriceDetailOutput> details,
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
decimal chargeableWeight) decimal chargeableWeight,
int? warehouseId = null,
string? warehouseName = null)
{ {
var cargoLabel = GetCargoAttributeLabel(cargoAttribute); var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
ChannelPriceDetailOutput? bestMatch = null; ChannelPriceDetailOutput? bestMatch = null;
@ -806,6 +861,11 @@ internal static class ParcelBillingCalculator
continue; continue;
} }
if (!MatchWarehouseFactor(factors, warehouseId, warehouseName))
{
continue;
}
if (!TryGetWeightFactorText(factors, out var weightText)) if (!TryGetWeightFactorText(factors, out var weightText))
{ {
if (bestMatch == null) if (bestMatch == null)
@ -911,6 +971,41 @@ internal static class ParcelBillingCalculator
|| string.Equals(actual.Trim(), cargoLabel, StringComparison.Ordinal); || string.Equals(actual.Trim(), cargoLabel, StringComparison.Ordinal);
} }
private static bool MatchWarehouseFactor(
Dictionary<string, string> 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( private static bool TryGetFactorValue(
Dictionary<string, string> factors, Dictionary<string, string> factors,
out string value, out string value,
@ -963,7 +1058,9 @@ internal static class ParcelBillingCalculator
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoAttribute, string cargoAttribute,
decimal chargeableWeight, decimal chargeableWeight,
CustomerGroup? customerGroup) CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null)
{ {
var cargoLabel = GetCargoAttributeLabel(cargoAttribute); var cargoLabel = GetCargoAttributeLabel(cargoAttribute);
return NormalizeCalculationFormula(detail.CalculationFormula) switch return NormalizeCalculationFormula(detail.CalculationFormula) switch
@ -976,7 +1073,9 @@ internal static class ParcelBillingCalculator
zoneKeys, zoneKeys,
cargoLabel, cargoLabel,
chargeableWeight, chargeableWeight,
customerGroup), customerGroup,
warehouseId,
warehouseName),
_ => detail.CalculatedValue ?? 0 _ => detail.CalculatedValue ?? 0
}; };
} }
@ -1002,7 +1101,9 @@ internal static class ParcelBillingCalculator
IReadOnlyList<string> zoneKeys, IReadOnlyList<string> zoneKeys,
string cargoLabel, string cargoLabel,
decimal chargeableWeight, decimal chargeableWeight,
CustomerGroup? customerGroup) CustomerGroup? customerGroup,
int? warehouseId = null,
string? warehouseName = null)
{ {
var factors = ParseFactorDictionary(detail.FactorValuesJson); var factors = ParseFactorDictionary(detail.FactorValuesJson);
if (TryGetWeightFactorText(factors, out var weightText) if (TryGetWeightFactorText(factors, out var weightText)
@ -1013,6 +1114,8 @@ internal static class ParcelBillingCalculator
cargoLabel, cargoLabel,
rangeStart, rangeStart,
customerGroup, customerGroup,
warehouseId,
warehouseName,
out var basePrice)) out var basePrice))
{ {
var continueUnitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0; var continueUnitPrice = ResolveCustomerGroupPrice(detail, customerGroup) ?? detail.CalculatedValue ?? 0;
@ -1039,13 +1142,17 @@ internal static class ParcelBillingCalculator
string cargoLabel, string cargoLabel,
decimal continueRangeStart, decimal continueRangeStart,
CustomerGroup? customerGroup, CustomerGroup? customerGroup,
int? warehouseId,
string? warehouseName,
out decimal basePrice) out decimal basePrice)
{ {
basePrice = 0; basePrice = 0;
foreach (var candidate in allDetails) foreach (var candidate in allDetails)
{ {
var factors = ParseFactorDictionary(candidate.FactorValuesJson); var factors = ParseFactorDictionary(candidate.FactorValuesJson);
if (!ZoneMatches(factors, zoneKeys) || !MatchCargoTypeFactor(factors, cargoLabel)) if (!ZoneMatches(factors, zoneKeys)
|| !MatchCargoTypeFactor(factors, cargoLabel)
|| !MatchWarehouseFactor(factors, warehouseId, warehouseName))
{ {
continue; continue;
} }

View File

@ -15,6 +15,13 @@ internal sealed class ParcelQuoteContext
public string CargoAttribute { get; init; } = string.Empty; public string CargoAttribute { get; init; } = string.Empty;
/// <summary>查价入参原始货物类型编码(字典 cargo_attribute)。</summary>
public string? CargoTypeCode { get; init; }
public int? WarehouseId { get; init; }
public string? WarehouseName { get; init; }
public decimal Length { get; init; } public decimal Length { get; init; }
public decimal Width { get; init; } public decimal Width { get; init; }

View File

@ -0,0 +1,44 @@
using Fake.Application;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 客户编号生成:W + 年份后2位 + 月份2位 + 3位顺序号,如 W2606001。
/// </summary>
public static class CustomerCodeGenerator
{
private const int MaxSequence = 999;
private const int MaxRetries = 5;
public static async Task<string> 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<string> 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}";
}
}

View File

@ -14,4 +14,9 @@ public interface ICustomerRepository : IRepository<Customer>
Task<Customer?> GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default); Task<Customer?> GetByWechatOpenidAsync(string wechatOpenid, CancellationToken cancellationToken = default);
Task<bool> ExistsPhoneAsync(string phone, CancellationToken cancellationToken = default); Task<bool> ExistsPhoneAsync(string phone, CancellationToken cancellationToken = default);
/// <summary>
/// 查询指定前缀(WyyMM)下已用顺序号的最大值。
/// </summary>
Task<int> GetMaxCustomerCodeSequenceForPrefixAsync(string prefix, CancellationToken cancellationToken = default);
} }

View File

@ -31,4 +31,33 @@ public class CustomerRepository : OmsSqlSugarRepository<Customer>, ICustomerRepo
{ {
return AnyAsync(x => x.Phone == phone, cancellationToken); return AnyAsync(x => x.Phone == phone, cancellationToken);
} }
public async Task<int> GetMaxCustomerCodeSequenceForPrefixAsync(
string prefix,
CancellationToken cancellationToken = default)
{
var expectedLength = prefix.Length + 3;
var db = await GetDbContextAsync(cancellationToken);
var codes = await db.Queryable<Customer>()
.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;
}
} }

View File

@ -50,8 +50,7 @@ public class WeChatProfileInput
public class WeChatCustomerRegisterInput public class WeChatCustomerRegisterInput
{ {
[Required] public string? CustomerCode { get; set; }
public string CustomerCode { get; set; } = string.Empty;
[Required] [Required]
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
@ -59,7 +58,9 @@ public class WeChatCustomerRegisterInput
[Required] [Required]
public string Phone { get; set; } = string.Empty; public string Phone { get; set; } = string.Empty;
public string? Email { get; set; } [Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required] [Required]
[MinLength(6)] [MinLength(6)]

View File

@ -80,10 +80,9 @@ public class WeChatCustomerAppService(
{ {
ValidateWechatProfile(input.Wechat); ValidateWechatProfile(input.Wechat);
var customerCode = NormalizeRequired(input.CustomerCode, "客户编号");
var customerName = NormalizeRequired(input.CustomerName, "客户名称"); var customerName = NormalizeRequired(input.CustomerName, "客户名称");
var phone = NormalizeRequired(input.Phone, "手机号"); var phone = NormalizeRequired(input.Phone, "手机号");
var email = NormalizeOptional(input.Email); var email = NormalizeRequired(input.Email, "邮箱");
var password = NormalizeRequired(input.Password, "密码"); var password = NormalizeRequired(input.Password, "密码");
if (password.Length < 6) if (password.Length < 6)
@ -96,6 +95,7 @@ public class WeChatCustomerAppService(
throw new BusinessException("当前微信已绑定账号"); throw new BusinessException("当前微信已绑定账号");
} }
var customerCode = await CustomerCodeGenerator.GenerateNextAsync(customerRepository, cancellationToken);
await EnsureCustomerIdentityUniqueAsync(customerCode, phone, email, cancellationToken); await EnsureCustomerIdentityUniqueAsync(customerCode, phone, email, cancellationToken);
var customerRoleId = await GetCustomerRoleIdOrThrowAsync(cancellationToken); var customerRoleId = await GetCustomerRoleIdOrThrowAsync(cancellationToken);
@ -193,7 +193,7 @@ public class WeChatCustomerAppService(
private async Task EnsureCustomerIdentityUniqueAsync( private async Task EnsureCustomerIdentityUniqueAsync(
string customerCode, string customerCode,
string phone, string phone,
string? email, string email,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await customerRepository.GetByCustomerCodeAsync(customerCode, cancellationToken) != null) if (await customerRepository.GetByCustomerCodeAsync(customerCode, cancellationToken) != null)
@ -206,7 +206,7 @@ public class WeChatCustomerAppService(
throw new BusinessException("手机号已存在"); throw new BusinessException("手机号已存在");
} }
if (email != null && await customerRepository.GetByEmailAsync(email, cancellationToken) != null) if (await customerRepository.GetByEmailAsync(email, cancellationToken) != null)
{ {
throw new BusinessException("邮箱已存在"); throw new BusinessException("邮箱已存在");
} }