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

View File

@ -29,4 +29,14 @@ public class OrderPriceCheckFailureOutput
/// 未命中原因说明。
/// </summary>
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; }
/// <summary>
/// 货物类型。
/// 货物类型(单选,兼容旧版;请优先使用 CargoTypes)。
/// </summary>
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>

View File

@ -15,6 +15,21 @@ public class OrderPriceCheckOutput
/// </summary>
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>

View File

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

View File

@ -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<NormalizedCustomerInput> 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<List<string>> NormalizeAndValidateSubCustomerPermissionsAsync(IEnumerable<string>? permissions)
@ -499,5 +503,5 @@ public class CustomerService : ApplicationService
string CustomerCode,
string CustomerName,
string Phone,
string? Email);
string Email);
}

View File

@ -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
{

View File

@ -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<OrderPriceCheckResultOutput> 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<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)
{
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<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) =>
string.IsNullOrWhiteSpace(postalCode) ? "-" : postalCode.Trim();
}

View File

@ -353,7 +353,9 @@ internal static class ParcelBillingCalculator
decimal chargeableWeight,
IReadOnlyList<string> 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<string> 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<string>();
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<string> 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);
}
/// <summary>
@ -697,12 +730,20 @@ internal static class ParcelBillingCalculator
IReadOnlyList<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage)
string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{
var fees = new List<OrderFee>();
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<string> zoneKeys,
string cargoAttribute,
CustomerGroup? customerGroup,
string chargingStage)
string chargingStage,
int? warehouseId = null,
string? warehouseName = null)
{
var fees = new List<OrderFee>();
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<ChannelPriceDetailOutput> details,
IReadOnlyList<string> 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<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(
Dictionary<string, string> factors,
out string value,
@ -963,7 +1058,9 @@ internal static class ParcelBillingCalculator
IReadOnlyList<string> 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<string> 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;
}

View File

@ -15,6 +15,13 @@ internal sealed class ParcelQuoteContext
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 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<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);
}
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
{
[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)]

View File

@ -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("邮箱已存在");
}